diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts index 4d5123852..e969f5206 100644 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts @@ -1,5 +1,5 @@ import type { ConfigIO } from '../../../../../../lib'; -import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../../../../schema'; +import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState, Memory } from '../../../../../../schema'; import * as harnessApi from '../../../../../aws/agentcore-harness'; import type { ImperativeDeployContext } from '../../types'; import { HarnessDeployer } from '../harness-deployer'; @@ -35,6 +35,7 @@ const CONFIG_ROOT = '/project/agentcore'; function createContext(overrides?: { harnesses?: AgentCoreProjectSpec['harnesses']; + memories?: Memory[]; deployedHarnesses?: DeployedState['targets'][string]['resources']; cdkOutputs?: Record; }): ImperativeDeployContext { @@ -43,7 +44,7 @@ function createContext(overrides?: { version: 1, managedBy: 'CDK' as const, runtimes: [], - memories: [], + memories: overrides?.memories ?? [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -535,6 +536,74 @@ describe('HarnessDeployer', () => { }); }); + describe('memorySpec resolution', () => { + const ROLE_ARN = 'arn:aws:iam::123456789012:role/HarnessRole'; + const MEMORY_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123'; + const CDK_OUTPUTS = { ApplicationHarnessMyHarnessRoleArnOutput123: ROLE_ARN }; + const READY_HARNESS = { + harnessId: 'h-new', + harnessName: 'my_harness', + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-new', + status: 'READY' as const, + executionRoleArn: ROLE_ARN, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + const HARNESS_SPEC_WITH_MEMORY_ARN_JSON = JSON.stringify({ + name: 'my_harness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' }, + tools: [], + skills: [], + memory: { arn: MEMORY_ARN }, + }); + + it('resolves memorySpec by deployed ARN when memory.name is absent', async () => { + const memory: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }], + }; + + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + memories: [memory], + deployedHarnesses: { + memories: { my_memory: { memoryId: 'mem-123', memoryArn: MEMORY_ARN } }, + }, + cdkOutputs: CDK_OUTPUTS, + }); + + mockedReadFile + .mockResolvedValueOnce(HARNESS_SPEC_WITH_MEMORY_ARN_JSON) + .mockRejectedValueOnce(new Error('ENOENT')); + mockedMapHarness.mockResolvedValueOnce({ region: REGION, harnessName: 'my_harness', executionRoleArn: ROLE_ARN }); + mockedCreateHarness.mockResolvedValueOnce({ harness: READY_HARNESS }); + + await deployer.deploy(ctx); + + expect(mockedMapHarness).toHaveBeenCalledWith(expect.objectContaining({ memorySpec: memory })); + }); + + it('returns undefined memorySpec for a fully external ARN not in deployedResources', async () => { + const ctx = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + memories: [], + cdkOutputs: CDK_OUTPUTS, + }); + + mockedReadFile + .mockResolvedValueOnce(HARNESS_SPEC_WITH_MEMORY_ARN_JSON) + .mockRejectedValueOnce(new Error('ENOENT')); + mockedMapHarness.mockResolvedValueOnce({ region: REGION, harnessName: 'my_harness', executionRoleArn: ROLE_ARN }); + mockedCreateHarness.mockResolvedValueOnce({ harness: READY_HARNESS }); + + await deployer.deploy(ctx); + + expect(mockedMapHarness).toHaveBeenCalledWith(expect.objectContaining({ memorySpec: undefined })); + }); + }); + describe('configHash', () => { const ROLE_ARN = 'arn:aws:iam::123456789012:role/HarnessRole'; const CDK_OUTPUTS = { ApplicationHarnessMyHarnessRoleArnOutput123: ROLE_ARN }; @@ -659,6 +728,72 @@ describe('HarnessDeployer', () => { ); expect(dockerfileCallArgs).toBeUndefined(); }); + + it('triggers update when memory strategy namespaces change', async () => { + const HARNESS_SPEC_WITH_MEMORY_JSON = JSON.stringify({ + name: 'my_harness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' }, + tools: [], + skills: [], + memory: { name: 'my_memory' }, + }); + + const memoryV1: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/v1'] }], + }; + + // First deploy — capture hash for memoryV1 + const ctxV1 = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + memories: [memoryV1], + cdkOutputs: CDK_OUTPUTS, + }); + + mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_WITH_MEMORY_JSON).mockRejectedValueOnce(new Error('ENOENT')); + mockedMapHarness.mockResolvedValueOnce({ region: REGION, harnessName: 'my_harness', executionRoleArn: ROLE_ARN }); + mockedCreateHarness.mockResolvedValueOnce({ harness: READY_HARNESS }); + + const result1 = await deployer.deploy(ctxV1); + const hashV1 = result1.state!.my_harness!.configHash; + + vi.clearAllMocks(); + + // Second deploy — only the namespace in memoryV1 changes, harness.json is identical + const memoryV2: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/v2'] }], + }; + + const ctxV2 = createContext({ + harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], + memories: [memoryV2], + deployedHarnesses: { + harnesses: { + my_harness: { + harnessId: 'h-new', + harnessArn: READY_HARNESS.arn, + roleArn: ROLE_ARN, + status: 'READY', + configHash: hashV1, + }, + }, + }, + cdkOutputs: CDK_OUTPUTS, + }); + + mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_WITH_MEMORY_JSON).mockRejectedValueOnce(new Error('ENOENT')); + mockedMapHarness.mockResolvedValueOnce({ region: REGION, harnessName: 'my_harness', executionRoleArn: ROLE_ARN }); + mockedUpdateHarness.mockResolvedValueOnce({ harness: READY_HARNESS }); + + const result2 = await deployer.deploy(ctxV2); + + expect(result2.state!.my_harness!.configHash).not.toBe(hashV1); + expect(mockedUpdateHarness).toHaveBeenCalled(); + expect(result2.notes).toContain('Updated harness "my_harness"'); + }); }); describe('teardown', () => { diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts index a2ee5256b..e5835d5dc 100644 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts @@ -1,4 +1,4 @@ -import type { DeployedResourceState, HarnessSpec } from '../../../../../../schema'; +import type { DeployedResourceState, HarnessSpec, Memory } from '../../../../../../schema'; import { mapHarnessSpecToCreateOptions } from '../harness-mapper'; import { readFile, stat } from 'fs/promises'; import { join } from 'path'; @@ -406,6 +406,201 @@ describe('mapHarnessSpecToCreateOptions', () => { 'Memory "nonexistent" referenced by harness is not in deployed state' ); }); + + it('includes retrievalConfig derived from memory strategy namespaces', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + const memorySpec: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [ + { type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }, + { type: 'USER_PREFERENCE', namespaces: ['/users/{actorId}/preferences'] }, + { type: 'SUMMARIZATION', namespaces: ['/summaries/{actorId}/{sessionId}'] }, + { + type: 'EPISODIC', + namespaces: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaces: ['/episodes/{actorId}'], + }, + ], + }; + + const spec = minimalSpec({ memory: { name: 'my_memory' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + memorySpec, + }); + + expect(result.memory).toEqual({ + agentCoreMemoryConfiguration: { + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + retrievalConfig: { + '/users/{actorId}/facts': {}, + '/users/{actorId}/preferences': {}, + '/summaries/{actorId}/{sessionId}': {}, + '/episodes/{actorId}/{sessionId}': {}, + '/episodes/{actorId}': {}, + }, + }, + }); + }); + + it('includes EPISODIC reflectionNamespaces in retrievalConfig', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + const memorySpec: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [ + { + type: 'EPISODIC', + namespaces: ['/episodes/{actorId}/{sessionId}'], + reflectionNamespaces: ['/episodes/{actorId}'], + }, + ], + }; + + const spec = minimalSpec({ memory: { name: 'my_memory' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + memorySpec, + }); + + expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toEqual({ + '/episodes/{actorId}/{sessionId}': {}, + '/episodes/{actorId}': {}, + }); + }); + + it('omits retrievalConfig when strategies have no namespaces or reflectionNamespaces', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + const memorySpec: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC' }, { type: 'SUMMARIZATION' }], + }; + + const spec = minimalSpec({ memory: { name: 'my_memory' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + memorySpec, + }); + + expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toBeUndefined(); + }); + + it('includes EPISODIC reflectionNamespaces in retrievalConfig even without namespaces', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + const memorySpec: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [ + { type: 'SEMANTIC' }, + { + type: 'EPISODIC', + reflectionNamespaces: ['/episodes/{actorId}'], + }, + ], + }; + + const spec = minimalSpec({ memory: { name: 'my_memory' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + memorySpec, + }); + + expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toEqual({ + '/episodes/{actorId}': {}, + }); + }); + + it('omits retrievalConfig when memorySpec not provided', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + + const spec = minimalSpec({ memory: { name: 'my_memory' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + }); + + expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toBeUndefined(); + }); + + it('includes both actorId and retrievalConfig when both are set', async () => { + const deployedResources: DeployedResourceState = { + memories: { + my_memory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + }, + }, + }; + const memorySpec: Memory = { + name: 'my_memory', + eventExpiryDuration: 30, + strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }], + }; + + const spec = minimalSpec({ memory: { name: 'my_memory', actorId: 'alice' } }); + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + deployedResources, + memorySpec, + }); + + expect(result.memory).toEqual({ + agentCoreMemoryConfiguration: { + arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', + actorId: 'alice', + retrievalConfig: { + '/users/{actorId}/facts': {}, + }, + }, + }); + }); }); // ── Truncation mapping ───────────────────────────────────────────────── diff --git a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts index ea5b31d5f..1bd8b2baf 100644 --- a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts +++ b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts @@ -5,7 +5,13 @@ * via the SigV4 API client. Harness role ARNs are resolved from CDK * stack outputs, and harness specs are read from disk (harness.json). */ -import type { HarnessDeployedState, HarnessSpec } from '../../../../../schema'; +import type { + DeployedResourceState, + HarnessDeployedState, + HarnessMemoryRef, + HarnessSpec, + Memory, +} from '../../../../../schema'; import { HarnessSpecSchema } from '../../../../../schema'; import type { CreateHarnessResult, @@ -32,10 +38,18 @@ const READY_POLL_MAX_ATTEMPTS = 40; // 2 minutes max type HarnessDeployedStateMap = Record; -async function computeHarnessHash(harnessDir: string, harnessSpec: HarnessSpec, roleArn: string): Promise { +async function computeHarnessHash( + harnessDir: string, + harnessSpec: HarnessSpec, + roleArn: string, + memorySpec?: Memory +): Promise { const hash = createHash('sha256'); hash.update(JSON.stringify(harnessSpec)); hash.update(roleArn); + if (memorySpec) { + hash.update(JSON.stringify(memorySpec)); + } try { const promptContent = await readFile(join(harnessDir, 'system-prompt.md'), 'utf-8'); hash.update(promptContent); @@ -53,6 +67,20 @@ async function computeHarnessHash(harnessDir: string, harnessSpec: HarnessSpec, return hash.digest('hex').slice(0, 16); } +function resolveMemorySpec( + memories: Memory[] | undefined, + memoryRef: HarnessMemoryRef | undefined, + deployedResources: DeployedResourceState | undefined +): Memory | undefined { + if (!memoryRef) return undefined; + if (memoryRef.name) return memories?.find(m => m.name === memoryRef.name); + if (memoryRef.arn && deployedResources?.memories) { + const entry = Object.entries(deployedResources.memories).find(([, v]) => v.memoryArn === memoryRef.arn); + if (entry) return memories?.find(m => m.name === entry[0]); + } + return undefined; +} + // ============================================================================ // Deployer // ============================================================================ @@ -132,8 +160,9 @@ export class HarnessDeployer implements ImperativeDeployer; + /** The memory spec for the memory this harness references, used to derive retrievalConfig namespaces. */ + memorySpec?: Memory; } /** * Transform a HarnessSpec into CreateHarnessOptions for the control plane API. */ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): Promise { - const { harnessSpec, harnessDir, executionRoleArn, region, projectName, deployedResources, cdkOutputs } = options; + const { harnessSpec, harnessDir, executionRoleArn, region, projectName, deployedResources, cdkOutputs, memorySpec } = + options; const result: CreateHarnessOptions = { region, @@ -77,7 +80,7 @@ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): // Memory if (harnessSpec.memory) { - result.memory = mapMemory(harnessSpec.memory, deployedResources, cdkOutputs); + result.memory = mapMemory(harnessSpec.memory, deployedResources, cdkOutputs, memorySpec); } // Truncation @@ -268,7 +271,8 @@ function mapSkills(skills: string[]): HarnessSkill[] { function mapMemory( memory: NonNullable, deployedResources?: DeployedResourceState, - cdkOutputs?: Record + cdkOutputs?: Record, + memorySpec?: Memory ): HarnessMemoryConfiguration | undefined { let arn: string | undefined; @@ -295,14 +299,32 @@ function mapMemory( return undefined; } + // Build retrievalConfig from the memory's strategy namespaces so the harness + // runtime knows which namespaces to search at inference time. + const retrievalConfig = buildRetrievalConfig(memorySpec); + return { agentCoreMemoryConfiguration: { arn, ...(memory.actorId && { actorId: memory.actorId }), + ...(retrievalConfig && { retrievalConfig }), }, }; } +function buildRetrievalConfig( + memorySpec: Memory | undefined +): Record | undefined { + if (!memorySpec?.strategies?.length) return undefined; + + const namespaces = memorySpec.strategies.flatMap(s => [ + ...(s.namespaces ?? []), + ...(s.type === 'EPISODIC' ? (s.reflectionNamespaces ?? []) : []), + ]); + + return namespaces.length > 0 ? Object.fromEntries(namespaces.map(ns => [ns, {}])) : undefined; +} + /** * Resolve memory ARN from CDK stack outputs. * The CDK construct exports memory ARNs with keys matching: diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 35a112fca..1260bd637 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -70,7 +70,13 @@ export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationCo export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; export type { HttpGatewayTarget } from './primitives/http-gateway'; export { HttpGatewayTargetSchema } from './primitives/http-gateway'; -export type { HarnessGatewayOutboundAuth, HarnessModel, HarnessSpec, HarnessModelProvider } from './primitives/harness'; +export type { + HarnessGatewayOutboundAuth, + HarnessMemoryRef, + HarnessModel, + HarnessSpec, + HarnessModelProvider, +} from './primitives/harness'; export { GatewayOAuthGrantTypeSchema, HarnessGatewayOutboundAuthSchema,