diff --git a/docs/memory.md b/docs/memory.md index 7841a6772..953cdfda9 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -229,6 +229,13 @@ pushed to a delivery target in your account, enabling event-driven architectures ### 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 diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index e23057398..83f36d69d 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -15,6 +15,7 @@ import { getSupportedModelProviders, matchEnumValue, } from '../../../schema'; +import { ARN_VALIDATION_MESSAGE, isValidArn } from '../shared/arn-utils'; import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import { validateJwtAuthorizerOptions } from './auth-options'; @@ -697,8 +698,8 @@ export function validateAddMemoryOptions(options: AddMemoryOptions): ValidationR return { valid: false, error: '--data-stream-arn is required when --delivery-type 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.dataStreamArn && !isValidArn(options.dataStreamArn)) { + return { valid: false, error: `--data-stream-arn: ${ARN_VALIDATION_MESSAGE}` }; } if ( diff --git a/src/cli/commands/shared/__tests__/arn-utils.test.ts b/src/cli/commands/shared/__tests__/arn-utils.test.ts new file mode 100644 index 000000000..9493995ae --- /dev/null +++ b/src/cli/commands/shared/__tests__/arn-utils.test.ts @@ -0,0 +1,28 @@ +import { isValidArn } from '../arn-utils'; +import { describe, expect, it } from 'vitest'; + +describe('isValidArn', () => { + it('accepts a valid Kinesis stream ARN', () => { + expect(isValidArn('arn:aws:kinesis:us-west-2:123456789012:stream/my-stream')).toBe(true); + }); + + it('accepts a valid Lambda ARN', () => { + expect(isValidArn('arn:aws:lambda:us-east-1:123456789012:function:my-func')).toBe(true); + }); + + it('rejects a string that does not start with arn:', () => { + expect(isValidArn('not-an-arn')).toBe(false); + }); + + it('rejects an ARN with too few parts', () => { + expect(isValidArn('arn:aws:kinesis:us-west-2:123456789012')).toBe(false); + }); + + it('accepts an ARN with colons in the resource part', () => { + expect(isValidArn('arn:aws:kinesis:us-west-2:123456789012:stream:extra:parts')).toBe(true); + }); + + it('rejects an empty string', () => { + expect(isValidArn('')).toBe(false); + }); +}); diff --git a/src/cli/commands/shared/arn-utils.ts b/src/cli/commands/shared/arn-utils.ts new file mode 100644 index 000000000..8ff30ac06 --- /dev/null +++ b/src/cli/commands/shared/arn-utils.ts @@ -0,0 +1,11 @@ +const ARN_PART_COUNT = 6; +const ARN_FORMAT = 'arn:partition:service:region:account:resource'; + +/** + * Check whether a string looks like a valid ARN (starts with `arn:` and has at least 6 colon-separated parts). + */ +export function isValidArn(value: string): boolean { + return value.startsWith('arn:') && value.split(':').length >= ARN_PART_COUNT; +} + +export const ARN_VALIDATION_MESSAGE = `Must be a valid ARN (${ARN_FORMAT})`; diff --git a/src/cli/tui/hooks/useCreateMemory.ts b/src/cli/tui/hooks/useCreateMemory.ts index 1eb6eca84..4345b4ead 100644 --- a/src/cli/tui/hooks/useCreateMemory.ts +++ b/src/cli/tui/hooks/useCreateMemory.ts @@ -8,6 +8,7 @@ interface CreateMemoryConfig { name: string; eventExpiryDuration: number; strategies: { type: string }[]; + streaming?: { dataStreamArn: string; contentLevel: string }; } interface CreateStatus { @@ -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 16375df15..da6cb3bdb 100644 --- a/src/cli/tui/screens/memory/AddMemoryScreen.tsx +++ b/src/cli/tui/screens/memory/AddMemoryScreen.tsx @@ -1,5 +1,6 @@ import type { MemoryStrategyType } from '../../../../schema'; -import { AgentNameSchema } from '../../../../schema'; +import { AgentNameSchema, StreamContentLevelSchema } from '../../../../schema'; +import { ARN_VALIDATION_MESSAGE, isValidArn } from '../../../commands/shared/arn-utils'; import { ConfirmReview, Panel, @@ -14,7 +15,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 +25,11 @@ interface AddMemoryScreenProps { existingMemoryNames: string[]; } +const STREAMING_OPTIONS: SelectableItem[] = [ + { id: 'no', title: 'No', description: 'No streaming' }, + { id: 'yes', title: 'Yes', description: 'Stream memory record events to a delivery target (e.g. Kinesis)' }, +]; + export function AddMemoryScreen({ onComplete, onExit, existingMemoryNames }: AddMemoryScreenProps) { const wizard = useAddMemoryWizard(); @@ -37,9 +43,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 +72,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(StreamContentLevelSchema.parse(item.id)), + onExit: () => wizard.goBack(), + isActive: isContentLevelStep, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -67,7 +95,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,8 +103,29 @@ 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 ( - + {isNameStep && ( )} - {isConfirmStep && ( - s.type).join(', ') || 'None' }, - ]} + {isStreamingStep && ( + + )} + + {isStreamArnStep && ( + wizard.goBack()} + customValidation={value => isValidArn(value) || ARN_VALIDATION_MESSAGE} /> )} + + {isContentLevelStep && ( + + )} + + {isConfirmStep && } ); diff --git a/src/cli/tui/screens/memory/types.ts b/src/cli/tui/screens/memory/types.ts index f94854645..f2b82f839 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', }; @@ -49,6 +58,15 @@ export const EVENT_EXPIRY_OPTIONS = [ { id: 365, title: '365 days', description: 'Maximum retention' }, ] as const; +export const CONTENT_LEVEL_OPTIONS = [ + { id: 'FULL_CONTENT' as const, title: 'Full content', description: 'Include memory record text in stream events' }, + { + id: 'METADATA_ONLY' as const, + title: 'Metadata only', + description: 'Only include metadata (IDs, timestamps, namespaces)', + }, +] as const satisfies readonly { id: StreamContentLevel; title: string; description: string }[]; + // ───────────────────────────────────────────────────────────────────────────── // Defaults // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/tui/screens/memory/useAddMemoryWizard.ts b/src/cli/tui/screens/memory/useAddMemoryWizard.ts index 98a0ad444..aca78e069 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 f0365a63c..854959b6c 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -202,7 +202,6 @@ describe('MemorySchema', () => { it('accepts memory with streamDeliveryResources', () => { const result = MemorySchema.safeParse({ - type: 'AgentCoreMemory', name: 'StreamMemory', eventExpiryDuration: 30, strategies: [{ type: 'SEMANTIC' }], @@ -220,9 +219,27 @@ describe('MemorySchema', () => { expect(result.success).toBe(true); }); + it('accepts METADATA_ONLY content level', () => { + const result = MemorySchema.safeParse({ + name: 'MetadataStream', + eventExpiryDuration: 30, + strategies: [], + streamDeliveryResources: { + resources: [ + { + kinesis: { + dataStreamArn: 'arn:aws:kinesis:us-west-2:123456789012:stream/test', + contentConfigurations: [{ type: 'MEMORY_RECORDS', level: 'METADATA_ONLY' }], + }, + }, + ], + }, + }); + expect(result.success).toBe(true); + }); + it('accepts memory without streamDeliveryResources', () => { const result = MemorySchema.safeParse({ - type: 'AgentCoreMemory', name: 'NoStream', eventExpiryDuration: 30, strategies: [], @@ -233,7 +250,6 @@ describe('MemorySchema', () => { it('rejects streamDeliveryResources with empty resources array', () => { const result = MemorySchema.safeParse({ - type: 'AgentCoreMemory', name: 'Test', eventExpiryDuration: 30, strategies: [], @@ -244,7 +260,6 @@ describe('MemorySchema', () => { it('rejects streamDeliveryResources with empty contentConfigurations', () => { const result = MemorySchema.safeParse({ - type: 'AgentCoreMemory', name: 'Test', eventExpiryDuration: 30, strategies: [], @@ -261,7 +276,6 @@ describe('MemorySchema', () => { it('rejects streamDeliveryResources with empty dataStreamArn', () => { const result = MemorySchema.safeParse({ - type: 'AgentCoreMemory', name: 'Test', eventExpiryDuration: 30, strategies: [], @@ -278,7 +292,6 @@ describe('MemorySchema', () => { it('rejects invalid content level in streamDeliveryResources', () => { const result = MemorySchema.safeParse({ - type: 'AgentCoreMemory', name: 'Test', eventExpiryDuration: 30, strategies: [],