From df02b8bcc035b0cb6bf20c9582d9f29b52eedb02 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 12:45:36 -0400 Subject: [PATCH 01/13] feat(project): add `project add memory` Registers a `memory` leaf under `project add`, following the same SDK-union -> flat project-schema conversion pattern as `project add harness`. A memory scaffolds no files, so the command only appends an entry to `spec.memories` in agentcore.json; the L3 CDK turns that into an `AWS::BedrockAgentCore::Memory` at deploy time. Flags: --name, --event-expiry-duration, --strategies, --indexed-keys, --stream-delivery-resources, --encryption-key-arn, --execution-role-arn, --tags. --strategies accepts two forms: a comma-separated list of strategy types expanded with the CLI's default namespace templates, or a JSON MemoryStrategyInput[] mirroring the CreateMemory API for strategies that need explicit names, descriptions, or namespaces. clientToken is excluded (it is CreateMemory idempotency and this command makes no API call), and description is excluded until the L3 CDK schema supports it. --- src/core/project/manager.tsx | 14 ++ src/handlers/project/add/index.ts | 2 + src/handlers/project/add/memory/index.ts | 271 +++++++++++++++++++++++ src/handlers/project/project.test.ts | 246 ++++++++++++++++++++ src/handlers/project/types.ts | 5 + 5 files changed, 538 insertions(+) create mode 100644 src/handlers/project/add/memory/index.ts diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 4a426ef1f..a6701ee19 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -21,6 +21,7 @@ import { } from "../../io"; import { defaultSource, type AssetSource } from "./source"; import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates"; +import { MemorySchema } from "../../projectSchemas/memory"; import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; import { @@ -167,6 +168,17 @@ export class FsProjectManager implements ProjectManager { }); break; } + case "memory": { + // A memory scaffolds no files: the spec entry is the whole resource. Validate + // it here so a malformed entry fails before the spec file is rewritten. + const memory = MemorySchema.safeParse(input.resourceConfig); + if (!memory.success) + throw new InputValidationError( + `invalid memory configuration: ${memory.error.issues.map((issue) => issue.message).join("; ")}`, + ); + newResources.push(memory.data); + break; + } case "runtime": { throw new NotImplementedError( "runtime case not yet implemented in FsProjectManager.addResource", @@ -312,5 +324,7 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "online-eval": case "online-insight": return "onlineEvalConfigs"; + case "memory": + return "memories"; } } diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 9c58cdcec..189350cdf 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -2,6 +2,7 @@ import { withProject } from "../../../middleware/"; import { Router } from "../../../router"; import { createAddConfigBundleHandler } from "./config-bundle"; import { createAddHarnessHandler } from "./harness"; +import { createAddMemoryHandler } from "./memory"; import { createAddOnlineEvalHandler } from "./online-eval"; import { createAddOnlineInsightHandler } from "./online-insight"; import type { AddProjectResourceConfig } from "./types"; @@ -11,6 +12,7 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.use(withProject({ projectManager: config.projectManager, cwd: process.cwd() })); projectAdd.handler(createAddConfigBundleHandler(config)); projectAdd.handler(createAddHarnessHandler(config)); + projectAdd.handler(createAddMemoryHandler(config)); projectAdd.handler(createAddOnlineEvalHandler(config)); projectAdd.handler(createAddOnlineInsightHandler(config)); return projectAdd; diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts new file mode 100644 index 000000000..d808b3538 --- /dev/null +++ b/src/handlers/project/add/memory/index.ts @@ -0,0 +1,271 @@ +import z from "zod"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; +import { parseJsonFlag } from "../../../utils"; +import { InputValidationError } from "../../../../errors"; +import type { + IndexedKey as SdkIndexedKey, + MemoryStrategyInput, + StreamDeliveryResources as SdkStreamDeliveryResources, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, + IndexedKeyTypeSchema, + MemoryStrategyTypeSchema, + StreamContentLevelSchema, + type IndexedKey, + type MemoryStrategy, + type StreamDeliveryResources, +} from "../../../../projectSchemas/memory"; + +// The service default for raw event retention, applied when --event-expiry-duration +// is omitted so the common case is a single --name. +const DEFAULT_EVENT_EXPIRY_DURATION = 30; + +const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) +The long-term memory strategies to extract from raw events. Accepts two forms. + +Shorthand — a comma-separated list of strategy types, each expanded with its +default namespace templates: + --strategies SEMANTIC,SUMMARIZATION + +JSON — a MemoryStrategyInput[] mirroring the CreateMemory API, for strategies +that need explicit names, descriptions, or namespaces. Exactly one of the +following keys can be set per entry: semanticMemoryStrategy, +summaryMemoryStrategy, userPreferenceMemoryStrategy, episodicMemoryStrategy. + +JSON syntax: + [ + { + "semanticMemoryStrategy": { + "name": "string", + "description": "string", + "namespaceTemplates": ["string", ...] + } + }, + { + "episodicMemoryStrategy": { + "name": "string", + "namespaceTemplates": ["string", ...], + "reflectionConfiguration": { + "namespaceTemplates": ["string", ...] // [required] for EPISODIC; each + // must prefix a namespaceTemplate + } + } + } + ] + +Example: + --strategies '[{"semanticMemoryStrategy":{"name":"facts","namespaceTemplates":["/users/{actorId}/facts"]}}]'`; + +export const createAddMemoryHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "memory", + description: "adds a memory to the current project", + flags: [ + flag("name", "the name of the memory", z.string().optional()), + flag( + "event-expiry-duration", + "how long raw events are retained, in days (3-365)", + z.number().int().min(3).max(365).default(DEFAULT_EVENT_EXPIRY_DURATION), + ), + flag( + "strategies", + "long-term memory strategies: comma-separated types, or JSON MemoryStrategyInput[]", + z.string().optional(), + { help: strategiesHelp }, + ), + flag( + "indexed-keys", + "metadata keys indexed for filtering (JSON IndexedKey[]); requires at least one strategy", + z.string().optional(), + ), + flag( + "stream-delivery-resources", + "destinations memory records are streamed to (JSON StreamDeliveryResources)", + z.string().optional(), + ), + flag( + "encryption-key-arn", + "customer managed KMS key ARN used to encrypt the memory", + z.string().optional(), + ), + flag( + "execution-role-arn", + "IAM role the memory assumes; a default role is created when omitted", + z.string().optional(), + ), + flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) + throw new InputValidationError("required option '--name ' not specified"); + + const inputIndexedKeys = parseJsonFlag( + "indexed-keys", + flags["indexed-keys"], + ); + const inputStreamDelivery = parseJsonFlag( + "stream-delivery-resources", + flags["stream-delivery-resources"], + ); + + const memoryConfig = { + name: flags.name, + eventExpiryDuration: flags["event-expiry-duration"], + strategies: flags["strategies"] ? toStrategies(flags["strategies"]) : undefined, + indexedKeys: inputIndexedKeys?.map(toIndexedKey), + encryptionKeyArn: flags["encryption-key-arn"], + executionRoleArn: flags["execution-role-arn"], + streamDeliveryResources: inputStreamDelivery + ? toStreamDeliveryResources(inputStreamDelivery) + : undefined, + tags: parseJsonFlag>("tags", flags["tags"]), + }; + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.addResource(project, { + resourceType: "memory", + resourceConfig: memoryConfig, + })) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`added memory '${flags["name"]}' to '${project.name}'\n`); + }, + }); + +/** + * Parses --strategies, which accepts either a comma-separated list of strategy + * types (expanded with the CLI's default namespaces) or a JSON + * MemoryStrategyInput[] mirroring the CreateMemory API. A leading '[' selects the + * JSON form; anything else is read as the shorthand. + */ +function toStrategies(raw: string): MemoryStrategy[] { + if (raw.trimStart().startsWith("[")) { + const inputs = parseJsonFlag("strategies", raw) ?? []; + if (!Array.isArray(inputs)) + throw new InputValidationError("Option '--strategies' JSON must be an array"); + return inputs.map(toStrategy); + } + return raw + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map(toDefaultStrategy); +} + +/** Expands a bare strategy type into a strategy carrying its default namespaces. */ +function toDefaultStrategy(type: string): MemoryStrategy { + const parsed = MemoryStrategyTypeSchema.safeParse(type); + if (!parsed.success) + throw new InputValidationError( + `unrecognized memory strategy '${type}'; expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, + ); + + return { + type: parsed.data, + namespaceTemplates: DEFAULT_STRATEGY_NAMESPACE_TEMPLATES[parsed.data], + // EPISODIC additionally requires reflection namespaces; the defaults are + // prefixes of the default episode namespaces, as the schema demands. + ...(parsed.data === "EPISODIC" && { + reflectionNamespaceTemplates: DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, + }), + }; +} + +/** Converts an SDK MemoryStrategyInput tagged union into the flat project-schema shape. */ +function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { + if ("semanticMemoryStrategy" in strategy && strategy.semanticMemoryStrategy) + return { type: "SEMANTIC", ...commonStrategyFields(strategy.semanticMemoryStrategy) }; + if ("summaryMemoryStrategy" in strategy && strategy.summaryMemoryStrategy) + return { type: "SUMMARIZATION", ...commonStrategyFields(strategy.summaryMemoryStrategy) }; + if ("userPreferenceMemoryStrategy" in strategy && strategy.userPreferenceMemoryStrategy) + return { + type: "USER_PREFERENCE", + ...commonStrategyFields(strategy.userPreferenceMemoryStrategy), + }; + if ("episodicMemoryStrategy" in strategy && strategy.episodicMemoryStrategy) { + const c = strategy.episodicMemoryStrategy; + return { + type: "EPISODIC", + ...commonStrategyFields(c), + reflectionNamespaceTemplates: c.reflectionConfiguration?.namespaceTemplates, + reflectionNamespaces: c.reflectionConfiguration?.namespaces, + }; + } + // The project spec models the four managed strategy types; a custom strategy has + // no representation in it (and no L3 construct to synthesize from). + if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) + throw new InputValidationError( + `customMemoryStrategy is not supported in a project spec; expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, + ); + throw new InputValidationError("Unrecognized memory strategy variant"); +} + +/** The fields every SDK memory strategy variant shares, in project-schema terms. */ +function commonStrategyFields(strategy: { + name?: string; + description?: string; + namespaces?: string[]; + namespaceTemplates?: string[]; +}) { + return { + name: strategy.name, + description: strategy.description, + namespaceTemplates: strategy.namespaceTemplates, + namespaces: strategy.namespaces, + }; +} + +/** Converts an SDK IndexedKey into the project-schema shape. */ +function toIndexedKey(indexedKey: SdkIndexedKey): IndexedKey { + const type = IndexedKeyTypeSchema.safeParse(indexedKey.type); + if (!type.success) + throw new InputValidationError( + `indexedKeys[].type must be one of ${IndexedKeyTypeSchema.options.join(", ")}`, + ); + return { key: requireField(indexedKey.key, "indexedKeys[].key"), type: type.data }; +} + +/** Converts an SDK StreamDeliveryResources into the project-schema shape. */ +function toStreamDeliveryResources( + streamDelivery: SdkStreamDeliveryResources, +): StreamDeliveryResources { + const resources = requireField(streamDelivery.resources, "streamDeliveryResources.resources").map( + (resource) => { + if (!("kinesis" in resource) || !resource.kinesis) + throw new InputValidationError("Unrecognized stream delivery resource variant"); + const kinesis = resource.kinesis; + return { + kinesis: { + dataStreamArn: requireField(kinesis.dataStreamArn, "kinesis.dataStreamArn"), + contentConfigurations: requireField( + kinesis.contentConfigurations, + "kinesis.contentConfigurations", + ).map((content) => { + if (content.type !== "MEMORY_RECORDS") + throw new InputValidationError( + `contentConfigurations[].type must be MEMORY_RECORDS, got '${String(content.type)}'`, + ); + const level = StreamContentLevelSchema.safeParse(content.level); + if (!level.success) + throw new InputValidationError( + `contentConfigurations[].level must be one of ${StreamContentLevelSchema.options.join(", ")}`, + ); + return { type: "MEMORY_RECORDS" as const, level: level.data }; + }), + }, + }; + }, + ); + + return { resources }; +} + +/** Validates a required field is present, throwing with context instead of crashing opaquely. */ +function requireField(value: T | undefined | null, field: string): T { + if (value == null) throw new InputValidationError(`${field} is required`); + return value; +} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ced2d9277..0d7402514 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -301,6 +301,252 @@ describe("project add config-bundle", () => { }); }); +describe("project add memory", () => { + /** Verify the flag -> agentcore.json memories[] entry for each flag. */ + test.each<[string, string[], Record]>([ + [ + "minimal — name only", + ["--name", "x"], + { name: "x", eventExpiryDuration: 30, strategies: [] }, + ], + [ + "event-expiry-duration", + ["--name", "x", "--event-expiry-duration", "7"], + { eventExpiryDuration: 7 }, + ], + [ + "strategies — shorthand, one type", + ["--name", "x", "--strategies", "SEMANTIC"], + { strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }] }, + ], + [ + "strategies — shorthand, several types with surrounding whitespace", + ["--name", "x", "--strategies", "SEMANTIC, SUMMARIZATION ,USER_PREFERENCE"], + { + strategies: [ + { type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }, + { type: "SUMMARIZATION", namespaceTemplates: ["/summaries/{actorId}/{sessionId}"] }, + { type: "USER_PREFERENCE", namespaceTemplates: ["/users/{actorId}/preferences"] }, + ], + }, + ], + [ + "strategies — shorthand EPISODIC also gets the default reflection namespaces", + ["--name", "x", "--strategies", "EPISODIC"], + { + strategies: [ + { + type: "EPISODIC", + namespaceTemplates: ["/episodes/{actorId}/{sessionId}"], + reflectionNamespaceTemplates: ["/episodes/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON semanticMemoryStrategy", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","description":"durable facts","namespaceTemplates":["/orgs/{actorId}"]}}]', + ], + { + strategies: [ + { + type: "SEMANTIC", + name: "facts", + description: "durable facts", + namespaceTemplates: ["/orgs/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON summaryMemoryStrategy maps to SUMMARIZATION", + ["--name", "x", "--strategies", '[{"summaryMemoryStrategy":{"name":"summaries"}}]'], + { strategies: [{ type: "SUMMARIZATION", name: "summaries" }] }, + ], + [ + "strategies — JSON userPreferenceMemoryStrategy maps to USER_PREFERENCE", + ["--name", "x", "--strategies", '[{"userPreferenceMemoryStrategy":{"name":"prefs"}}]'], + { strategies: [{ type: "USER_PREFERENCE", name: "prefs" }] }, + ], + [ + "strategies — JSON episodicMemoryStrategy hoists reflectionConfiguration", + [ + "--name", + "x", + "--strategies", + '[{"episodicMemoryStrategy":{"name":"episodes","namespaceTemplates":["/episodes/{actorId}/{sessionId}"],"reflectionConfiguration":{"namespaceTemplates":["/episodes/{actorId}"]}}}]', + ], + { + strategies: [ + { + type: "EPISODIC", + name: "episodes", + namespaceTemplates: ["/episodes/{actorId}/{sessionId}"], + reflectionNamespaceTemplates: ["/episodes/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON deprecated namespaces are preserved", + ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{"namespaces":["/legacy"]}}]'], + { strategies: [{ type: "SEMANTIC", namespaces: ["/legacy"] }] }, + ], + [ + "indexed-keys", + [ + "--name", + "x", + "--strategies", + "SEMANTIC", + "--indexed-keys", + '[{"key":"tenant","type":"STRING"},{"key":"score","type":"NUMBER"}]', + ], + { + indexedKeys: [ + { key: "tenant", type: "STRING" }, + { key: "score", type: "NUMBER" }, + ], + }, + ], + [ + "stream-delivery-resources", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}', + ], + { + streamDeliveryResources: { + resources: [ + { + kinesis: { + dataStreamArn: "arn:aws:kinesis:us-east-1:123456789012:stream/s", + contentConfigurations: [{ type: "MEMORY_RECORDS", level: "FULL_CONTENT" }], + }, + }, + ], + }, + }, + ], + [ + "encryption-key-arn and execution-role-arn", + [ + "--name", + "x", + "--encryption-key-arn", + "arn:aws:kms:us-east-1:123456789012:key/abc", + "--execution-role-arn", + "arn:aws:iam::123456789012:role/MyMemoryRole", + ], + { + encryptionKeyArn: "arn:aws:kms:us-east-1:123456789012:key/abc", + executionRoleArn: "arn:aws:iam::123456789012:role/MyMemoryRole", + }, + ], + ["tags", ["--name", "x", "--tags", '{"team":"ml"}'], { tags: { team: "ml" } }], + ])("%s", async (_label, flags, expected) => { + const projectRoot = await inProject(); + await run(["add", "memory", ...flags]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(agentcoreJson.memories).toHaveLength(1); + expect(agentcoreJson.memories[0]).toMatchObject(expected); + }); + + test("adds no files under app/", async () => { + const projectRoot = await inProject(); + await run(["add", "memory", "--name", "x"]); + + expect(existsSync(join(projectRoot, "app", "x"))).toBe(false); + }); + + test("rejects a duplicate memory name", async () => { + await inProject(); + await run(["add", "memory", "--name", "x"]); + await expect(run(["add", "memory", "--name", "x"])).rejects.toBeInstanceOf( + InputValidationError, + ); + }); + + test.each([ + ["missing --name", ["--event-expiry-duration", "30"]], + ["invalid name", ["--name", "1bad"]], + ["event-expiry-duration below the minimum", ["--name", "x", "--event-expiry-duration", "2"]], + ["event-expiry-duration above the maximum", ["--name", "x", "--event-expiry-duration", "400"]], + ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], + ["duplicate shorthand strategy", ["--name", "x", "--strategies", "SEMANTIC,SEMANTIC"]], + ["unrecognized JSON strategy variant", ["--name", "x", "--strategies", '[{"unknown":{}}]']], + [ + "customMemoryStrategy has no project-spec representation", + ["--name", "x", "--strategies", '[{"customMemoryStrategy":{"name":"c"}}]'], + ], + [ + "episodic strategy without reflection namespaces", + ["--name", "x", "--strategies", '[{"episodicMemoryStrategy":{"name":"episodes"}}]'], + ], + [ + "namespaces and namespaceTemplates are mutually exclusive", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"namespaces":["/a"],"namespaceTemplates":["/b"]}}]', + ], + ], + [ + "indexed-keys without a strategy", + ["--name", "x", "--indexed-keys", '[{"key":"tenant","type":"STRING"}]'], + ], + [ + "indexed-keys with an unsupported type", + [ + "--name", + "x", + "--strategies", + "SEMANTIC", + "--indexed-keys", + '[{"key":"tenant","type":"BOOLEAN"}]', + ], + ], + [ + "indexed-keys without a key", + ["--name", "x", "--strategies", "SEMANTIC", "--indexed-keys", '[{"type":"STRING"}]'], + ], + [ + "unrecognized stream delivery resource variant", + ["--name", "x", "--stream-delivery-resources", '{"resources":[{"firehose":{}}]}'], + ], + [ + "stream delivery resource without a dataStreamArn", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}', + ], + ], + [ + "stream delivery content configuration without a level", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS"}]}}]}', + ], + ], + ["malformed --strategies JSON", ["--name", "x", "--strategies", "[{"]], + ])("%s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "memory", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); + describe("project build", () => { async function inBuildableProject(): Promise { const projectRoot = await inProject("MyAgent"); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index e804d10f9..afa0014da 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,5 +1,6 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; +import type { MemorySchema } from "../../projectSchemas/memory"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; import type z from "zod"; import type { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; @@ -63,6 +64,10 @@ export type AddResourceInput = | { resourceType: "online-insight"; resourceConfig: z.input; + } + | { + resourceType: "memory"; + resourceConfig: z.input; }; export type ProjectResource = AddResourceInput["resourceType"]; From ce4be16484300fe61abde5617696ca46d7ed6e08 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 13:03:42 -0400 Subject: [PATCH 02/13] feat: add --description to 'project add memory' Stores an optional memory description in agentcore.json, matching the CreateMemory API's description field (max 4096 characters). The generated CDK app pins @aws/agentcore-cdk 0.1.0-alpha.45, whose MemorySchema is a non-strict z.object with no description field, so the key is stripped at synth rather than rejected until aws/agentcore-l3-cdk-constructs#325 ships and that pin is bumped. The flag help text says so. --- src/handlers/project/add/memory/index.ts | 18 ++++++++++++++++++ src/handlers/project/project.test.ts | 19 ++++++++++++++++++- src/projectSchemas/memory.ts | 2 ++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index d808b3538..ef9017432 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -23,6 +23,20 @@ import { // is omitted so the common case is a single --name. const DEFAULT_EVENT_EXPIRY_DURATION = 30; +// TODO: drop the deploy-time caveat once the generated CDK app pins an +// @aws/agentcore-cdk release containing the memory `description` field +// (aws/agentcore-l3-cdk-constructs#325). Its MemorySchema is non-strict, so +// until then the field is stripped at synth rather than rejected. +const descriptionHelp = `(string) +A description of what the memory stores, carried on the memory resource. Up to +4096 characters. + +Until the generated CDK app's @aws/agentcore-cdk dependency supports this +field, it is stored in agentcore.json but not applied at deploy. + +Example: + --description 'Durable facts and preferences for each end user.'`; + const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) The long-term memory strategies to extract from raw events. Accepts two forms. @@ -65,6 +79,9 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => description: "adds a memory to the current project", flags: [ flag("name", "the name of the memory", z.string().optional()), + flag("description", "a description of what the memory stores", z.string().optional(), { + help: descriptionHelp, + }), flag( "event-expiry-duration", "how long raw events are retained, in days (3-365)", @@ -113,6 +130,7 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => const memoryConfig = { name: flags.name, + description: flags["description"], eventExpiryDuration: flags["event-expiry-duration"], strategies: flags["strategies"] ? toStrategies(flags["strategies"]) : undefined, indexedKeys: inputIndexedKeys?.map(toIndexedKey), diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 0d7402514..59c9a8e80 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,7 +10,9 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; -import { InputValidationError } from "../../errors"; +import { DeserializationError, InputValidationError } from "../../errors"; +import { FsReadWriteJson, type ReadWriteJson } from "../../io"; +import { MEMORY_DESCRIPTION_MAX_LENGTH } from "../../projectSchemas/memory"; async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -309,6 +311,16 @@ describe("project add memory", () => { ["--name", "x"], { name: "x", eventExpiryDuration: 30, strategies: [] }, ], + [ + "description", + ["--name", "x", "--description", "Durable facts and preferences for each end user."], + { description: "Durable facts and preferences for each end user." }, + ], + [ + "description — at the maximum length", + ["--name", "x", "--description", "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH)], + { description: "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH) }, + ], [ "event-expiry-duration", ["--name", "x", "--event-expiry-duration", "7"], @@ -477,6 +489,11 @@ describe("project add memory", () => { test.each([ ["missing --name", ["--event-expiry-duration", "30"]], ["invalid name", ["--name", "1bad"]], + ["empty description", ["--name", "x", "--description", ""]], + [ + "description above the maximum length", + ["--name", "x", "--description", "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH + 1)], + ], ["event-expiry-duration below the minimum", ["--name", "x", "--event-expiry-duration", "2"]], ["event-expiry-duration above the maximum", ["--name", "x", "--event-expiry-duration", "400"]], ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], diff --git a/src/projectSchemas/memory.ts b/src/projectSchemas/memory.ts index 9da703168..722429681 100644 --- a/src/projectSchemas/memory.ts +++ b/src/projectSchemas/memory.ts @@ -142,9 +142,11 @@ export const IndexedKeySchema = z.object({ type: IndexedKeyTypeSchema, }); export type IndexedKey = z.infer; +export const MEMORY_DESCRIPTION_MAX_LENGTH = 4096; export const MemorySchema = z .object({ name: MemoryNameSchema, + description: z.string().min(1).max(MEMORY_DESCRIPTION_MAX_LENGTH).optional(), eventExpiryDuration: z.number().int().min(3).max(365), strategies: z .array(MemoryStrategySchema) From 3900c2af9d01077d6028870cc9562540ab21333d Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 14:43:21 -0400 Subject: [PATCH 03/13] feat: accept a CUSTOM memory strategy in 'project add memory' The CDK's memory schema already models CUSTOM (@aws/agentcore-cdk 0.1.0-alpha.45 maps it to CFN customMemoryStrategy), so the CLI's four-type enum was the outlier. A customMemoryStrategy in the --strategies JSON now converts to { type: 'CUSTOM', name, description, namespaceTemplates }. The shorthand form still takes managed types only: CUSTOM has no default namespaces to expand. An extraction configuration or memoryRecordSchema is rejected rather than dropped, since the CDK schema carries neither. Also names the offending field in the memory validation error. --- src/core/project/manager.tsx | 6 ++- src/handlers/project/add/memory/index.ts | 53 +++++++++++++++++------- src/handlers/project/project.test.ts | 53 +++++++++++++++++++++++- src/projectSchemas/memory.ts | 15 ++++++- 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index a6701ee19..8019d07ad 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -174,7 +174,11 @@ export class FsProjectManager implements ProjectManager { const memory = MemorySchema.safeParse(input.resourceConfig); if (!memory.success) throw new InputValidationError( - `invalid memory configuration: ${memory.error.issues.map((issue) => issue.message).join("; ")}`, + `invalid memory configuration: ${memory.error.issues + .map((issue) => + issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message, + ) + .join("; ")}`, ); newResources.push(memory.data); break; diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index ef9017432..8963b3982 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -12,7 +12,7 @@ import { DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, IndexedKeyTypeSchema, - MemoryStrategyTypeSchema, + ManagedMemoryStrategyTypeSchema, StreamContentLevelSchema, type IndexedKey, type MemoryStrategy, @@ -40,14 +40,17 @@ Example: const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) The long-term memory strategies to extract from raw events. Accepts two forms. -Shorthand — a comma-separated list of strategy types, each expanded with its -default namespace templates: +Shorthand — a comma-separated list of managed strategy types, each expanded with +its default namespace templates: --strategies SEMANTIC,SUMMARIZATION JSON — a MemoryStrategyInput[] mirroring the CreateMemory API, for strategies -that need explicit names, descriptions, or namespaces. Exactly one of the -following keys can be set per entry: semanticMemoryStrategy, -summaryMemoryStrategy, userPreferenceMemoryStrategy, episodicMemoryStrategy. +that need explicit names, descriptions, or namespaces, and for custom +strategies, which have no defaults to expand. Exactly one of the following keys +can be set per entry: semanticMemoryStrategy, summaryMemoryStrategy, +userPreferenceMemoryStrategy, episodicMemoryStrategy, customMemoryStrategy. + +A memory holds at most one strategy of each type. JSON syntax: [ @@ -58,6 +61,13 @@ JSON syntax: "namespaceTemplates": ["string", ...] } }, + { + "customMemoryStrategy": { + "name": "string", // [required] + "description": "string", + "namespaceTemplates": ["string", ...] + } + }, { "episodicMemoryStrategy": { "name": "string", @@ -174,12 +184,17 @@ function toStrategies(raw: string): MemoryStrategy[] { .map(toDefaultStrategy); } -/** Expands a bare strategy type into a strategy carrying its default namespaces. */ +/** + * Expands a bare strategy type into a strategy carrying its default namespaces. + * Only the managed types are accepted here: CUSTOM has no default namespaces to + * expand, so it has to come through the JSON form. + */ function toDefaultStrategy(type: string): MemoryStrategy { - const parsed = MemoryStrategyTypeSchema.safeParse(type); + const parsed = ManagedMemoryStrategyTypeSchema.safeParse(type); if (!parsed.success) throw new InputValidationError( - `unrecognized memory strategy '${type}'; expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, + `unrecognized memory strategy '${type}'; expected one of ${ManagedMemoryStrategyTypeSchema.options.join(", ")}` + + ` (a CUSTOM strategy has no defaults to expand; pass it as JSON instead)`, ); return { @@ -213,12 +228,20 @@ function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { reflectionNamespaces: c.reflectionConfiguration?.namespaces, }; } - // The project spec models the four managed strategy types; a custom strategy has - // no representation in it (and no L3 construct to synthesize from). - if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) - throw new InputValidationError( - `customMemoryStrategy is not supported in a project spec; expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, - ); + if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) { + const c = strategy.customMemoryStrategy; + // A strategy in the project spec carries a name, a description and namespaces, + // which is all the CDK's memory schema models. An extraction override or record + // schema would be dropped on the way to CloudFormation, so reject it here + // instead of reporting a memory the service did not configure that way. + for (const unsupported of ["configuration", "memoryRecordSchema"] as const) { + if (c[unsupported]) + throw new InputValidationError( + `customMemoryStrategy.${unsupported} is not supported yet; a CUSTOM strategy carries only name, description and namespaceTemplates`, + ); + } + return { type: "CUSTOM", ...commonStrategyFields(c) }; + } throw new InputValidationError("Unrecognized memory strategy variant"); } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 59c9a8e80..9e34abf8e 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -403,6 +403,40 @@ describe("project add memory", () => { ], }, ], + [ + "strategies — JSON customMemoryStrategy maps to CUSTOM", + [ + "--name", + "x", + "--strategies", + '[{"customMemoryStrategy":{"name":"tickets","description":"support tickets","namespaceTemplates":["/tickets/{actorId}"]}}]', + ], + { + strategies: [ + { + type: "CUSTOM", + name: "tickets", + description: "support tickets", + namespaceTemplates: ["/tickets/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON customMemoryStrategy alongside a managed strategy", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts"}},{"customMemoryStrategy":{"name":"tickets"}}]', + ], + { + strategies: [ + { type: "SEMANTIC", name: "facts" }, + { type: "CUSTOM", name: "tickets" }, + ], + }, + ], [ "strategies — JSON deprecated namespaces are preserved", ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{"namespaces":["/legacy"]}}]'], @@ -499,9 +533,24 @@ describe("project add memory", () => { ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], ["duplicate shorthand strategy", ["--name", "x", "--strategies", "SEMANTIC,SEMANTIC"]], ["unrecognized JSON strategy variant", ["--name", "x", "--strategies", '[{"unknown":{}}]']], + ["CUSTOM has no shorthand form", ["--name", "x", "--strategies", "CUSTOM"]], + [ + "customMemoryStrategy with an extraction configuration", + [ + "--name", + "x", + "--strategies", + '[{"customMemoryStrategy":{"name":"c","configuration":{"semanticOverride":{"extraction":{"appendToPrompt":"p","modelId":"m"}}}}}]', + ], + ], [ - "customMemoryStrategy has no project-spec representation", - ["--name", "x", "--strategies", '[{"customMemoryStrategy":{"name":"c"}}]'], + "customMemoryStrategy with a memory record schema", + [ + "--name", + "x", + "--strategies", + '[{"customMemoryStrategy":{"name":"c","memoryRecordSchema":{"definition":{}}}}]', + ], ], [ "episodic strategy without reflection namespaces", diff --git a/src/projectSchemas/memory.ts b/src/projectSchemas/memory.ts index 722429681..2e67c875e 100644 --- a/src/projectSchemas/memory.ts +++ b/src/projectSchemas/memory.ts @@ -5,10 +5,23 @@ export const MemoryStrategyTypeSchema = z.enum([ "SEMANTIC", "SUMMARIZATION", "USER_PREFERENCE", + "CUSTOM", "EPISODIC", ]); export type MemoryStrategyType = z.infer; -export const DEFAULT_STRATEGY_NAMESPACE_TEMPLATES: Partial> = { +/** + * The strategy types the service extracts with a built-in prompt. Each has a + * default namespace, so naming the type is enough to configure one — unlike + * CUSTOM, whose namespaces the caller supplies. + */ +export const ManagedMemoryStrategyTypeSchema = z.enum([ + "SEMANTIC", + "SUMMARIZATION", + "USER_PREFERENCE", + "EPISODIC", +]); +export type ManagedMemoryStrategyType = z.infer; +export const DEFAULT_STRATEGY_NAMESPACE_TEMPLATES: Record = { SEMANTIC: ["/users/{actorId}/facts"], USER_PREFERENCE: ["/users/{actorId}/preferences"], SUMMARIZATION: ["/summaries/{actorId}/{sessionId}"], From 5bb84e4c4a8e3fad08a12f5cbbe8351e975549fd Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 15:06:05 -0400 Subject: [PATCH 04/13] revert: drop CUSTOM memory strategy from "project add memory" Reverts 87be86e0. I added CUSTOM because the CDK schema already had it in MemoryStrategyTypeSchema, which turns out to be the argument PR #694 made -- and #713 reverted a day later. The CLI has removed CUSTOM twice on purpose. Offering the type without somewhere to put its extraction configuration is #241 ("select custom memory strategy, note there is no option to add prompts"); #266 removed it as a P0 to stop users picking an unsupported option, #694/#696 added it back with semanticOverride, and #713 reverted both as premature. #676 tracks doing it properly. The CDK keeping CUSTOM in its enum without a configuration field is the same hole, not a licence. So both forms are rejected again, now with an error that says why and points at #676. The one thing kept from the reverted commit: memory validation errors name the offending field, since issue.path was being dropped. --- src/handlers/project/add/memory/index.ts | 57 ++++++++---------------- src/handlers/project/project.test.ts | 56 +++-------------------- src/projectSchemas/memory.ts | 15 +------ 3 files changed, 25 insertions(+), 103 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 8963b3982..64ca1ff80 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -12,7 +12,7 @@ import { DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, IndexedKeyTypeSchema, - ManagedMemoryStrategyTypeSchema, + MemoryStrategyTypeSchema, StreamContentLevelSchema, type IndexedKey, type MemoryStrategy, @@ -40,17 +40,14 @@ Example: const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) The long-term memory strategies to extract from raw events. Accepts two forms. -Shorthand — a comma-separated list of managed strategy types, each expanded with -its default namespace templates: +Shorthand — a comma-separated list of strategy types, each expanded with its +default namespace templates: --strategies SEMANTIC,SUMMARIZATION JSON — a MemoryStrategyInput[] mirroring the CreateMemory API, for strategies -that need explicit names, descriptions, or namespaces, and for custom -strategies, which have no defaults to expand. Exactly one of the following keys -can be set per entry: semanticMemoryStrategy, summaryMemoryStrategy, -userPreferenceMemoryStrategy, episodicMemoryStrategy, customMemoryStrategy. - -A memory holds at most one strategy of each type. +that need explicit names, descriptions, or namespaces. Exactly one of the +following keys can be set per entry: semanticMemoryStrategy, +summaryMemoryStrategy, userPreferenceMemoryStrategy, episodicMemoryStrategy. JSON syntax: [ @@ -61,13 +58,6 @@ JSON syntax: "namespaceTemplates": ["string", ...] } }, - { - "customMemoryStrategy": { - "name": "string", // [required] - "description": "string", - "namespaceTemplates": ["string", ...] - } - }, { "episodicMemoryStrategy": { "name": "string", @@ -184,17 +174,12 @@ function toStrategies(raw: string): MemoryStrategy[] { .map(toDefaultStrategy); } -/** - * Expands a bare strategy type into a strategy carrying its default namespaces. - * Only the managed types are accepted here: CUSTOM has no default namespaces to - * expand, so it has to come through the JSON form. - */ +/** Expands a bare strategy type into a strategy carrying its default namespaces. */ function toDefaultStrategy(type: string): MemoryStrategy { - const parsed = ManagedMemoryStrategyTypeSchema.safeParse(type); + const parsed = MemoryStrategyTypeSchema.safeParse(type); if (!parsed.success) throw new InputValidationError( - `unrecognized memory strategy '${type}'; expected one of ${ManagedMemoryStrategyTypeSchema.options.join(", ")}` + - ` (a CUSTOM strategy has no defaults to expand; pass it as JSON instead)`, + `unrecognized memory strategy '${type}'; expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, ); return { @@ -228,20 +213,16 @@ function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { reflectionNamespaces: c.reflectionConfiguration?.namespaces, }; } - if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) { - const c = strategy.customMemoryStrategy; - // A strategy in the project spec carries a name, a description and namespaces, - // which is all the CDK's memory schema models. An extraction override or record - // schema would be dropped on the way to CloudFormation, so reject it here - // instead of reporting a memory the service did not configure that way. - for (const unsupported of ["configuration", "memoryRecordSchema"] as const) { - if (c[unsupported]) - throw new InputValidationError( - `customMemoryStrategy.${unsupported} is not supported yet; a CUSTOM strategy carries only name, description and namespaceTemplates`, - ); - } - return { type: "CUSTOM", ...commonStrategyFields(c) }; - } + // CUSTOM is left out on purpose. Its point is the extraction configuration -- + // prompt and model overrides, or a self-managed pipeline -- and neither the + // project spec nor the CDK's memory schema can carry one. Offering the type + // without it produced aws/agentcore-cli#241 ("select custom memory strategy, + // note there is no option to add prompts"), so it was removed in #266 and again + // in #713. Re-enable it alongside the configuration, not before: #676 tracks it. + if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) + throw new InputValidationError( + `customMemoryStrategy is not supported: a custom strategy's extraction configuration cannot be expressed yet (see aws/agentcore-cli#676). Expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, + ); throw new InputValidationError("Unrecognized memory strategy variant"); } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 9e34abf8e..517237e35 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -403,40 +403,6 @@ describe("project add memory", () => { ], }, ], - [ - "strategies — JSON customMemoryStrategy maps to CUSTOM", - [ - "--name", - "x", - "--strategies", - '[{"customMemoryStrategy":{"name":"tickets","description":"support tickets","namespaceTemplates":["/tickets/{actorId}"]}}]', - ], - { - strategies: [ - { - type: "CUSTOM", - name: "tickets", - description: "support tickets", - namespaceTemplates: ["/tickets/{actorId}"], - }, - ], - }, - ], - [ - "strategies — JSON customMemoryStrategy alongside a managed strategy", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"facts"}},{"customMemoryStrategy":{"name":"tickets"}}]', - ], - { - strategies: [ - { type: "SEMANTIC", name: "facts" }, - { type: "CUSTOM", name: "tickets" }, - ], - }, - ], [ "strategies — JSON deprecated namespaces are preserved", ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{"namespaces":["/legacy"]}}]'], @@ -533,24 +499,12 @@ describe("project add memory", () => { ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], ["duplicate shorthand strategy", ["--name", "x", "--strategies", "SEMANTIC,SEMANTIC"]], ["unrecognized JSON strategy variant", ["--name", "x", "--strategies", '[{"unknown":{}}]']], - ["CUSTOM has no shorthand form", ["--name", "x", "--strategies", "CUSTOM"]], - [ - "customMemoryStrategy with an extraction configuration", - [ - "--name", - "x", - "--strategies", - '[{"customMemoryStrategy":{"name":"c","configuration":{"semanticOverride":{"extraction":{"appendToPrompt":"p","modelId":"m"}}}}}]', - ], - ], + // CUSTOM is rejected in both forms until a custom strategy's extraction + // configuration can be expressed. See aws/agentcore-cli#241, #266, #713, #676. + ["CUSTOM shorthand strategy", ["--name", "x", "--strategies", "CUSTOM"]], [ - "customMemoryStrategy with a memory record schema", - [ - "--name", - "x", - "--strategies", - '[{"customMemoryStrategy":{"name":"c","memoryRecordSchema":{"definition":{}}}}]', - ], + "customMemoryStrategy JSON variant", + ["--name", "x", "--strategies", '[{"customMemoryStrategy":{"name":"c"}}]'], ], [ "episodic strategy without reflection namespaces", diff --git a/src/projectSchemas/memory.ts b/src/projectSchemas/memory.ts index 2e67c875e..722429681 100644 --- a/src/projectSchemas/memory.ts +++ b/src/projectSchemas/memory.ts @@ -5,23 +5,10 @@ export const MemoryStrategyTypeSchema = z.enum([ "SEMANTIC", "SUMMARIZATION", "USER_PREFERENCE", - "CUSTOM", "EPISODIC", ]); export type MemoryStrategyType = z.infer; -/** - * The strategy types the service extracts with a built-in prompt. Each has a - * default namespace, so naming the type is enough to configure one — unlike - * CUSTOM, whose namespaces the caller supplies. - */ -export const ManagedMemoryStrategyTypeSchema = z.enum([ - "SEMANTIC", - "SUMMARIZATION", - "USER_PREFERENCE", - "EPISODIC", -]); -export type ManagedMemoryStrategyType = z.infer; -export const DEFAULT_STRATEGY_NAMESPACE_TEMPLATES: Record = { +export const DEFAULT_STRATEGY_NAMESPACE_TEMPLATES: Partial> = { SEMANTIC: ["/users/{actorId}/facts"], USER_PREFERENCE: ["/users/{actorId}/preferences"], SUMMARIZATION: ["/summaries/{actorId}/{sessionId}"], From 30f6ac1725693045dbd0fb03e7039010609324ba Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 16:45:45 -0400 Subject: [PATCH 05/13] refactor: drop the long-form help for --description The one-line flag description is enough; the deploy-time caveat lives in the PR discussion rather than in help output. --- src/handlers/project/add/memory/index.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 64ca1ff80..063a8b5b9 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -23,20 +23,6 @@ import { // is omitted so the common case is a single --name. const DEFAULT_EVENT_EXPIRY_DURATION = 30; -// TODO: drop the deploy-time caveat once the generated CDK app pins an -// @aws/agentcore-cdk release containing the memory `description` field -// (aws/agentcore-l3-cdk-constructs#325). Its MemorySchema is non-strict, so -// until then the field is stripped at synth rather than rejected. -const descriptionHelp = `(string) -A description of what the memory stores, carried on the memory resource. Up to -4096 characters. - -Until the generated CDK app's @aws/agentcore-cdk dependency supports this -field, it is stored in agentcore.json but not applied at deploy. - -Example: - --description 'Durable facts and preferences for each end user.'`; - const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) The long-term memory strategies to extract from raw events. Accepts two forms. @@ -79,9 +65,7 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => description: "adds a memory to the current project", flags: [ flag("name", "the name of the memory", z.string().optional()), - flag("description", "a description of what the memory stores", z.string().optional(), { - help: descriptionHelp, - }), + flag("description", "a description of what the memory stores", z.string().optional()), flag( "event-expiry-duration", "how long raw events are retained, in days (3-365)", From 523a49689fd97db1db200289daf9fb09348d024a Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 17:05:05 -0400 Subject: [PATCH 06/13] docs: comment change --- src/handlers/project/add/memory/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 063a8b5b9..283ee2691 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -19,8 +19,7 @@ import { type StreamDeliveryResources, } from "../../../../projectSchemas/memory"; -// The service default for raw event retention, applied when --event-expiry-duration -// is omitted so the common case is a single --name. +// The service default for raw event retention const DEFAULT_EVENT_EXPIRY_DURATION = 30; const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) @@ -197,12 +196,7 @@ function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { reflectionNamespaces: c.reflectionConfiguration?.namespaces, }; } - // CUSTOM is left out on purpose. Its point is the extraction configuration -- - // prompt and model overrides, or a self-managed pipeline -- and neither the - // project spec nor the CDK's memory schema can carry one. Offering the type - // without it produced aws/agentcore-cli#241 ("select custom memory strategy, - // note there is no option to add prompts"), so it was removed in #266 and again - // in #713. Re-enable it alongside the configuration, not before: #676 tracks it. + /** Custom & Self-managed memory are not supported at this point. */ if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) throw new InputValidationError( `customMemoryStrategy is not supported: a custom strategy's extraction configuration cannot be expressed yet (see aws/agentcore-cli#676). Expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, From ef5ac82ab27740109176d78fe30bd167da776568 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 18:19:44 -0400 Subject: [PATCH 07/13] fix: change function name and add comment for clarity --- src/handlers/project/add/memory/index.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 283ee2691..38edce474 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -179,19 +179,19 @@ function toDefaultStrategy(type: string): MemoryStrategy { /** Converts an SDK MemoryStrategyInput tagged union into the flat project-schema shape. */ function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { if ("semanticMemoryStrategy" in strategy && strategy.semanticMemoryStrategy) - return { type: "SEMANTIC", ...commonStrategyFields(strategy.semanticMemoryStrategy) }; + return { type: "SEMANTIC", ...toProjectStrategyFields(strategy.semanticMemoryStrategy) }; if ("summaryMemoryStrategy" in strategy && strategy.summaryMemoryStrategy) - return { type: "SUMMARIZATION", ...commonStrategyFields(strategy.summaryMemoryStrategy) }; + return { type: "SUMMARIZATION", ...toProjectStrategyFields(strategy.summaryMemoryStrategy) }; if ("userPreferenceMemoryStrategy" in strategy && strategy.userPreferenceMemoryStrategy) return { type: "USER_PREFERENCE", - ...commonStrategyFields(strategy.userPreferenceMemoryStrategy), + ...toProjectStrategyFields(strategy.userPreferenceMemoryStrategy), }; if ("episodicMemoryStrategy" in strategy && strategy.episodicMemoryStrategy) { const c = strategy.episodicMemoryStrategy; return { type: "EPISODIC", - ...commonStrategyFields(c), + ...toProjectStrategyFields(c), reflectionNamespaceTemplates: c.reflectionConfiguration?.namespaceTemplates, reflectionNamespaces: c.reflectionConfiguration?.namespaces, }; @@ -199,13 +199,17 @@ function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { /** Custom & Self-managed memory are not supported at this point. */ if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) throw new InputValidationError( - `customMemoryStrategy is not supported: a custom strategy's extraction configuration cannot be expressed yet (see aws/agentcore-cli#676). Expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, + `customMemoryStrategy is not supported. Expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, ); throw new InputValidationError("Unrecognized memory strategy variant"); } -/** The fields every SDK memory strategy variant shares, in project-schema terms. */ -function commonStrategyFields(strategy: { +/** + * Picks only the fields the project schema supports from an SDK strategy variant. + * Avoids spreading to keep unsupported SDK fields (e.g. memoryRecordSchema, + * reflectionConfiguration, configuration) out of the stored spec. + */ +function toProjectStrategyFields(strategy: { name?: string; description?: string; namespaces?: string[]; From 154a37dedb22ef949027f0bca7d22a9831dbd518 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 18:45:54 -0400 Subject: [PATCH 08/13] test: add uncovered unsupported stream content type test --- src/handlers/project/project.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 517237e35..ddd4ab17a 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -560,6 +560,15 @@ describe("project add memory", () => { '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS"}]}}]}', ], ], + [ + "stream delivery content configuration with an unsupported type", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"EVENTS","level":"FULL_CONTENT"}]}}]}', + ], + ], ["malformed --strategies JSON", ["--name", "x", "--strategies", "[{"]], ])("%s", async (_label, flags) => { await inProject(); From f2e2f53911c71f473a3a59857f0b9a796fa951d4 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 18:46:44 -0400 Subject: [PATCH 09/13] style: make json example concrete, remove comments --- src/handlers/project/add/memory/index.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 38edce474..5c2bb2f8b 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -34,29 +34,25 @@ that need explicit names, descriptions, or namespaces. Exactly one of the following keys can be set per entry: semanticMemoryStrategy, summaryMemoryStrategy, userPreferenceMemoryStrategy, episodicMemoryStrategy. -JSON syntax: +JSON example: [ { "semanticMemoryStrategy": { - "name": "string", - "description": "string", - "namespaceTemplates": ["string", ...] + "name": "facts", + "description": "Durable user facts", + "namespaceTemplates": ["/users/{actorId}/facts"] } }, { "episodicMemoryStrategy": { - "name": "string", - "namespaceTemplates": ["string", ...], + "name": "episodes", + "namespaceTemplates": ["/episodes/{actorId}/{sessionId}"], "reflectionConfiguration": { - "namespaceTemplates": ["string", ...] // [required] for EPISODIC; each - // must prefix a namespaceTemplate + "namespaceTemplates": ["/episodes/{actorId}"] } } } - ] - -Example: - --strategies '[{"semanticMemoryStrategy":{"name":"facts","namespaceTemplates":["/users/{actorId}/facts"]}}]'`; + ]`; export const createAddMemoryHandler = (config: AddProjectResourceConfig) => createHandler({ From f16e20a4adfcde3efa1b027f6d38cea83e2a2276 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 19:54:38 -0400 Subject: [PATCH 10/13] refactor(project): reuse shared spec validation for memory --- src/core/project/manager.tsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 8019d07ad..40abbd13e 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -21,7 +21,6 @@ import { } from "../../io"; import { defaultSource, type AssetSource } from "./source"; import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates"; -import { MemorySchema } from "../../projectSchemas/memory"; import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; import { @@ -168,21 +167,6 @@ export class FsProjectManager implements ProjectManager { }); break; } - case "memory": { - // A memory scaffolds no files: the spec entry is the whole resource. Validate - // it here so a malformed entry fails before the spec file is rewritten. - const memory = MemorySchema.safeParse(input.resourceConfig); - if (!memory.success) - throw new InputValidationError( - `invalid memory configuration: ${memory.error.issues - .map((issue) => - issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message, - ) - .join("; ")}`, - ); - newResources.push(memory.data); - break; - } case "runtime": { throw new NotImplementedError( "runtime case not yet implemented in FsProjectManager.addResource", @@ -191,6 +175,7 @@ export class FsProjectManager implements ProjectManager { case "config-bundle": case "online-eval": case "online-insight": + case "memory": newResources.push(resourceConfig); break; From 65ede793bbad83642f9059698a90e723905b880f Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 21:54:47 -0400 Subject: [PATCH 11/13] fix(project): validate memory JSON inputs --- src/handlers/project/add/memory/index.ts | 178 ++++++++++++----------- src/handlers/project/project.test.ts | 51 +++++++ 2 files changed, 146 insertions(+), 83 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 5c2bb2f8b..662f51f20 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -1,27 +1,97 @@ import z from "zod"; import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; -import { parseJsonFlag } from "../../../utils"; +import { parseJsonFlagWithSchema } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import type { - IndexedKey as SdkIndexedKey, - MemoryStrategyInput, - StreamDeliveryResources as SdkStreamDeliveryResources, -} from "@aws-sdk/client-bedrock-agentcore-control"; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, - IndexedKeyTypeSchema, + IndexedKeySchema, + MemoryStrategyNameSchema, MemoryStrategyTypeSchema, - StreamContentLevelSchema, - type IndexedKey, + StreamDeliveryResourcesSchema, type MemoryStrategy, - type StreamDeliveryResources, } from "../../../../projectSchemas/memory"; +import { TagsSchema } from "../../../../projectSchemas/tags"; // The service default for raw event retention const DEFAULT_EVENT_EXPIRY_DURATION = 30; +const strategyFields = { + name: MemoryStrategyNameSchema.optional(), + description: z.string().optional(), + namespaces: z.array(z.string()).optional(), + namespaceTemplates: z.array(z.string()).optional(), +}; + +function projectMemoryObject(shape: T, label: string) { + const supportedFields = new Set(Object.keys(shape)); + return z + .object(shape) + .passthrough() + .superRefine((value, ctx) => { + for (const field of Object.keys(value)) { + if (!supportedFields.has(field)) { + ctx.addIssue({ + code: "custom", + path: [field], + message: `${label} field '${field}' is not supported by project memory resources`, + }); + } + } + }); +} + +const StandardStrategyInputSchema = projectMemoryObject(strategyFields, "memory strategy"); +const EpisodicStrategyInputSchema = projectMemoryObject( + { + ...strategyFields, + reflectionConfiguration: projectMemoryObject( + { + namespaces: z.array(z.string()).optional(), + namespaceTemplates: z.array(z.string()).optional(), + }, + "episodic reflection configuration", + ).optional(), + }, + "episodic memory strategy", +); + +const STRATEGY_MEMBER_KEYS = [ + "semanticMemoryStrategy", + "summaryMemoryStrategy", + "userPreferenceMemoryStrategy", + "episodicMemoryStrategy", + "customMemoryStrategy", +] as const; + +const MemoryStrategyInputSchema = projectMemoryObject( + { + semanticMemoryStrategy: StandardStrategyInputSchema.optional(), + summaryMemoryStrategy: StandardStrategyInputSchema.optional(), + userPreferenceMemoryStrategy: StandardStrategyInputSchema.optional(), + episodicMemoryStrategy: EpisodicStrategyInputSchema.optional(), + customMemoryStrategy: z.unknown().optional(), + }, + "memory strategy input", +).superRefine((strategy, ctx) => { + const members = STRATEGY_MEMBER_KEYS.filter((key) => strategy[key] !== undefined); + if (members.length !== 1) { + ctx.addIssue({ + code: "custom", + message: `Exactly one memory strategy member must be specified; received ${members.length}`, + }); + } + if (members[0] === "customMemoryStrategy") { + ctx.addIssue({ + code: "custom", + path: ["customMemoryStrategy"], + message: "customMemoryStrategy is not supported by project memory resources", + }); + } +}); +type ProjectMemoryStrategyInput = z.infer; + const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) The long-term memory strategies to extract from raw events. Accepts two forms. @@ -98,13 +168,15 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => if (!flags.name) throw new InputValidationError("required option '--name ' not specified"); - const inputIndexedKeys = parseJsonFlag( + const inputIndexedKeys = parseJsonFlagWithSchema( "indexed-keys", flags["indexed-keys"], + z.array(IndexedKeySchema), ); - const inputStreamDelivery = parseJsonFlag( + const inputStreamDelivery = parseJsonFlagWithSchema( "stream-delivery-resources", flags["stream-delivery-resources"], + StreamDeliveryResourcesSchema, ); const memoryConfig = { @@ -112,13 +184,11 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => description: flags["description"], eventExpiryDuration: flags["event-expiry-duration"], strategies: flags["strategies"] ? toStrategies(flags["strategies"]) : undefined, - indexedKeys: inputIndexedKeys?.map(toIndexedKey), + indexedKeys: inputIndexedKeys, encryptionKeyArn: flags["encryption-key-arn"], executionRoleArn: flags["execution-role-arn"], - streamDeliveryResources: inputStreamDelivery - ? toStreamDeliveryResources(inputStreamDelivery) - : undefined, - tags: parseJsonFlag>("tags", flags["tags"]), + streamDeliveryResources: inputStreamDelivery, + tags: parseJsonFlagWithSchema("tags", flags["tags"], TagsSchema), }; const project = ctx.require(ProjectKey); @@ -141,9 +211,8 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => */ function toStrategies(raw: string): MemoryStrategy[] { if (raw.trimStart().startsWith("[")) { - const inputs = parseJsonFlag("strategies", raw) ?? []; - if (!Array.isArray(inputs)) - throw new InputValidationError("Option '--strategies' JSON must be an array"); + const inputs = + parseJsonFlagWithSchema("strategies", raw, z.array(MemoryStrategyInputSchema)) ?? []; return inputs.map(toStrategy); } return raw @@ -173,17 +242,17 @@ function toDefaultStrategy(type: string): MemoryStrategy { } /** Converts an SDK MemoryStrategyInput tagged union into the flat project-schema shape. */ -function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { - if ("semanticMemoryStrategy" in strategy && strategy.semanticMemoryStrategy) +function toStrategy(strategy: ProjectMemoryStrategyInput): MemoryStrategy { + if (strategy.semanticMemoryStrategy) return { type: "SEMANTIC", ...toProjectStrategyFields(strategy.semanticMemoryStrategy) }; - if ("summaryMemoryStrategy" in strategy && strategy.summaryMemoryStrategy) + if (strategy.summaryMemoryStrategy) return { type: "SUMMARIZATION", ...toProjectStrategyFields(strategy.summaryMemoryStrategy) }; - if ("userPreferenceMemoryStrategy" in strategy && strategy.userPreferenceMemoryStrategy) + if (strategy.userPreferenceMemoryStrategy) return { type: "USER_PREFERENCE", ...toProjectStrategyFields(strategy.userPreferenceMemoryStrategy), }; - if ("episodicMemoryStrategy" in strategy && strategy.episodicMemoryStrategy) { + if (strategy.episodicMemoryStrategy) { const c = strategy.episodicMemoryStrategy; return { type: "EPISODIC", @@ -192,18 +261,12 @@ function toStrategy(strategy: MemoryStrategyInput): MemoryStrategy { reflectionNamespaces: c.reflectionConfiguration?.namespaces, }; } - /** Custom & Self-managed memory are not supported at this point. */ - if ("customMemoryStrategy" in strategy && strategy.customMemoryStrategy) - throw new InputValidationError( - `customMemoryStrategy is not supported. Expected one of ${MemoryStrategyTypeSchema.options.join(", ")}`, - ); throw new InputValidationError("Unrecognized memory strategy variant"); } /** * Picks only the fields the project schema supports from an SDK strategy variant. - * Avoids spreading to keep unsupported SDK fields (e.g. memoryRecordSchema, - * reflectionConfiguration, configuration) out of the stored spec. + * The JSON input schema rejects unsupported fields before this conversion. */ function toProjectStrategyFields(strategy: { name?: string; @@ -218,54 +281,3 @@ function toProjectStrategyFields(strategy: { namespaces: strategy.namespaces, }; } - -/** Converts an SDK IndexedKey into the project-schema shape. */ -function toIndexedKey(indexedKey: SdkIndexedKey): IndexedKey { - const type = IndexedKeyTypeSchema.safeParse(indexedKey.type); - if (!type.success) - throw new InputValidationError( - `indexedKeys[].type must be one of ${IndexedKeyTypeSchema.options.join(", ")}`, - ); - return { key: requireField(indexedKey.key, "indexedKeys[].key"), type: type.data }; -} - -/** Converts an SDK StreamDeliveryResources into the project-schema shape. */ -function toStreamDeliveryResources( - streamDelivery: SdkStreamDeliveryResources, -): StreamDeliveryResources { - const resources = requireField(streamDelivery.resources, "streamDeliveryResources.resources").map( - (resource) => { - if (!("kinesis" in resource) || !resource.kinesis) - throw new InputValidationError("Unrecognized stream delivery resource variant"); - const kinesis = resource.kinesis; - return { - kinesis: { - dataStreamArn: requireField(kinesis.dataStreamArn, "kinesis.dataStreamArn"), - contentConfigurations: requireField( - kinesis.contentConfigurations, - "kinesis.contentConfigurations", - ).map((content) => { - if (content.type !== "MEMORY_RECORDS") - throw new InputValidationError( - `contentConfigurations[].type must be MEMORY_RECORDS, got '${String(content.type)}'`, - ); - const level = StreamContentLevelSchema.safeParse(content.level); - if (!level.success) - throw new InputValidationError( - `contentConfigurations[].level must be one of ${StreamContentLevelSchema.options.join(", ")}`, - ); - return { type: "MEMORY_RECORDS" as const, level: level.data }; - }), - }, - }; - }, - ); - - return { resources }; -} - -/** Validates a required field is present, throwing with context instead of crashing opaquely. */ -function requireField(value: T | undefined | null, field: string): T { - if (value == null) throw new InputValidationError(`${field} is required`); - return value; -} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ddd4ab17a..332f88c19 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -574,6 +574,57 @@ describe("project add memory", () => { await inProject(); await expect(run(["add", "memory", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); + + test.each<[string, string[], RegExp]>([ + [ + "rejects multiple strategy union members", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts"},"summaryMemoryStrategy":{"name":"summaries"}}]', + ], + /Exactly one memory strategy member must be specified; received 2/, + ], + [ + "rejects unsupported strategy fields", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","memoryRecordSchema":{}}}]', + ], + /memory strategy field 'memoryRecordSchema' is not supported by project memory resources/, + ], + [ + "rejects unsupported episodic reflection fields", + [ + "--name", + "x", + "--strategies", + '[{"episodicMemoryStrategy":{"name":"episodes","namespaceTemplates":["/episodes/{actorId}/{sessionId}"],"reflectionConfiguration":{"namespaceTemplates":["/episodes/{actorId}"],"memoryRecordSchema":{}}}}]', + ], + /episodic reflection configuration field 'memoryRecordSchema' is not supported by project memory resources/, + ], + [ + "validates indexed-keys as an array", + ["--name", "x", "--indexed-keys", '{"key":"tenant","type":"STRING"}'], + /Invalid value for option '--indexed-keys'/, + ], + [ + "validates stream delivery resources as an array", + ["--name", "x", "--stream-delivery-resources", '{"resources":{}}'], + /Invalid value for option '--stream-delivery-resources'/, + ], + [ + "validates tags as a string map", + ["--name", "x", "--tags", '["team=ml"]'], + /Invalid value for option '--tags'/, + ], + ])("%s", async (_label, flags, error) => { + await inProject(); + await expect(run(["add", "memory", ...flags])).rejects.toThrow(error); + }); }); describe("project build", () => { From 776ffaac1236b7069f0556f18702c01fbdfef2b3 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 18 Aug 2026 22:35:58 -0400 Subject: [PATCH 12/13] fix(project): harden memory input validation --- src/handlers/project/add/memory/index.ts | 59 ++++++++++++----- src/handlers/project/project.test.ts | 82 +++++++++++++++++++++++- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/src/handlers/project/add/memory/index.ts b/src/handlers/project/add/memory/index.ts index 662f51f20..e6776b998 100644 --- a/src/handlers/project/add/memory/index.ts +++ b/src/handlers/project/add/memory/index.ts @@ -9,7 +9,7 @@ import { IndexedKeySchema, MemoryStrategyNameSchema, MemoryStrategyTypeSchema, - StreamDeliveryResourcesSchema, + StreamContentLevelSchema, type MemoryStrategy, } from "../../../../projectSchemas/memory"; import { TagsSchema } from "../../../../projectSchemas/tags"; @@ -18,7 +18,7 @@ import { TagsSchema } from "../../../../projectSchemas/tags"; const DEFAULT_EVENT_EXPIRY_DURATION = 30; const strategyFields = { - name: MemoryStrategyNameSchema.optional(), + name: MemoryStrategyNameSchema, description: z.string().optional(), namespaces: z.array(z.string()).optional(), namespaceTemplates: z.array(z.string()).optional(), @@ -27,9 +27,9 @@ const strategyFields = { function projectMemoryObject(shape: T, label: string) { const supportedFields = new Set(Object.keys(shape)); return z - .object(shape) - .passthrough() + .unknown() .superRefine((value, ctx) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return; for (const field of Object.keys(value)) { if (!supportedFields.has(field)) { ctx.addIssue({ @@ -39,7 +39,8 @@ function projectMemoryObject(shape: T, label: string) { }); } } - }); + }) + .pipe(z.object(shape)); } const StandardStrategyInputSchema = projectMemoryObject(strategyFields, "memory strategy"); @@ -92,6 +93,34 @@ const MemoryStrategyInputSchema = projectMemoryObject( }); type ProjectMemoryStrategyInput = z.infer; +const IndexedKeyInputSchema = projectMemoryObject(IndexedKeySchema.shape, "indexed key"); +const StreamContentConfigurationInputSchema = projectMemoryObject( + { + type: z.literal("MEMORY_RECORDS"), + level: StreamContentLevelSchema, + }, + "stream content configuration", +); +const KinesisStreamDeliveryInputSchema = projectMemoryObject( + { + dataStreamArn: z.string().min(1), + contentConfigurations: z.array(StreamContentConfigurationInputSchema).min(1), + }, + "Kinesis stream delivery resource", +); +const StreamDeliveryResourceInputSchema = projectMemoryObject( + { + kinesis: KinesisStreamDeliveryInputSchema, + }, + "stream delivery resource", +); +const StreamDeliveryResourcesInputSchema = projectMemoryObject( + { + resources: z.array(StreamDeliveryResourceInputSchema).min(1), + }, + "stream delivery resources", +); + const strategiesHelp = `(comma-separated list of strategy types, or JSON MemoryStrategyInput[]) The long-term memory strategies to extract from raw events. Accepts two forms. @@ -171,12 +200,12 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => const inputIndexedKeys = parseJsonFlagWithSchema( "indexed-keys", flags["indexed-keys"], - z.array(IndexedKeySchema), + z.array(IndexedKeyInputSchema), ); const inputStreamDelivery = parseJsonFlagWithSchema( "stream-delivery-resources", flags["stream-delivery-resources"], - StreamDeliveryResourcesSchema, + StreamDeliveryResourcesInputSchema, ); const memoryConfig = { @@ -206,20 +235,20 @@ export const createAddMemoryHandler = (config: AddProjectResourceConfig) => /** * Parses --strategies, which accepts either a comma-separated list of strategy * types (expanded with the CLI's default namespaces) or a JSON - * MemoryStrategyInput[] mirroring the CreateMemory API. A leading '[' selects the - * JSON form; anything else is read as the shorthand. + * MemoryStrategyInput[] mirroring the CreateMemory API. A leading JSON container + * selects the JSON form; anything else is read as the shorthand. */ function toStrategies(raw: string): MemoryStrategy[] { - if (raw.trimStart().startsWith("[")) { + const trimmed = raw.trimStart(); + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { const inputs = parseJsonFlagWithSchema("strategies", raw, z.array(MemoryStrategyInputSchema)) ?? []; return inputs.map(toStrategy); } - return raw - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map(toDefaultStrategy); + const entries = raw.split(",").map((entry) => entry.trim()); + if (entries.some((entry) => entry.length === 0)) + throw new InputValidationError("memory strategy list cannot contain empty entries"); + return entries.map(toDefaultStrategy); } /** Expands a bare strategy type into a strategy carrying its default namespaces. */ diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 332f88c19..d5fd37914 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -405,8 +405,13 @@ describe("project add memory", () => { ], [ "strategies — JSON deprecated namespaces are preserved", - ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{"namespaces":["/legacy"]}}]'], - { strategies: [{ type: "SEMANTIC", namespaces: ["/legacy"] }] }, + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"legacy","namespaces":["/legacy"]}}]', + ], + { strategies: [{ type: "SEMANTIC", name: "legacy", namespaces: ["/legacy"] }] }, ], [ "indexed-keys", @@ -497,8 +502,17 @@ describe("project add memory", () => { ["event-expiry-duration below the minimum", ["--name", "x", "--event-expiry-duration", "2"]], ["event-expiry-duration above the maximum", ["--name", "x", "--event-expiry-duration", "400"]], ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], + ["empty shorthand strategy entry", ["--name", "x", "--strategies", "SEMANTIC,"]], ["duplicate shorthand strategy", ["--name", "x", "--strategies", "SEMANTIC,SEMANTIC"]], ["unrecognized JSON strategy variant", ["--name", "x", "--strategies", '[{"unknown":{}}]']], + [ + "JSON strategy without its required name", + ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{}}]'], + ], + [ + "JSON strategy input must be an array", + ["--name", "x", "--strategies", '{"semanticMemoryStrategy":{"name":"facts"}}'], + ], // CUSTOM is rejected in both forms until a custom strategy's extraction // configuration can be expressed. See aws/agentcore-cli#241, #266, #713, #676. ["CUSTOM shorthand strategy", ["--name", "x", "--strategies", "CUSTOM"]], @@ -516,7 +530,7 @@ describe("project add memory", () => { "--name", "x", "--strategies", - '[{"semanticMemoryStrategy":{"namespaces":["/a"],"namespaceTemplates":["/b"]}}]', + '[{"semanticMemoryStrategy":{"name":"facts","namespaces":["/a"],"namespaceTemplates":["/b"]}}]', ], ], [ @@ -606,16 +620,78 @@ describe("project add memory", () => { ], /episodic reflection configuration field 'memoryRecordSchema' is not supported by project memory resources/, ], + [ + "rejects prototype-named unsupported strategy fields", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","__proto__":{"polluted":true}}}]', + ], + /memory strategy field '__proto__' is not supported by project memory resources/, + ], [ "validates indexed-keys as an array", ["--name", "x", "--indexed-keys", '{"key":"tenant","type":"STRING"}'], /Invalid value for option '--indexed-keys'/, ], + [ + "rejects unsupported indexed-key fields", + [ + "--name", + "x", + "--strategies", + "SEMANTIC", + "--indexed-keys", + '[{"key":"tenant","type":"STRING","unexpected":true}]', + ], + /indexed key field 'unexpected' is not supported by project memory resources/, + ], [ "validates stream delivery resources as an array", ["--name", "x", "--stream-delivery-resources", '{"resources":{}}'], /Invalid value for option '--stream-delivery-resources'/, ], + [ + "rejects unsupported top-level stream delivery fields", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}],"unexpected":true}', + ], + /stream delivery resources field 'unexpected' is not supported by project memory resources/, + ], + [ + "rejects unsupported stream delivery resource variants", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]},"firehose":{}}]}', + ], + /stream delivery resource field 'firehose' is not supported by project memory resources/, + ], + [ + "rejects unsupported Kinesis stream delivery fields", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}],"unexpected":true}}]}', + ], + /Kinesis stream delivery resource field 'unexpected' is not supported by project memory resources/, + ], + [ + "rejects unsupported nested stream delivery fields", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT","unexpected":true}]}}]}', + ], + /stream content configuration field 'unexpected' is not supported by project memory resources/, + ], [ "validates tags as a string map", ["--name", "x", "--tags", '["team=ml"]'], From 1e902a76b551a92c416c429063fa09e262a8662b Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 21 Aug 2026 12:17:02 -0400 Subject: [PATCH 13/13] test(project): colocate memory tests with the add/memory handler Upstream moved the per-resource `project add` tests out of the monolithic project.test.ts into colocated add//index.test.ts suites (harness in #2034, online-eval in #2048). Move the memory tests to match, with the same locally-duplicated run/inProject helpers those suites use. project.test.ts is now identical to upstream/refactor again, so this PR no longer touches it. Also drops the DeserializationError, FsReadWriteJson and ReadWriteJson imports, left dead there once the harness tests that used them moved to add/harness/index.test.ts. No test content changed: 187 project tests still pass, now across 10 files instead of 9. --- src/handlers/project/add/memory/index.test.ts | 454 ++++++++++++++++++ src/handlers/project/project.test.ts | 404 +--------------- 2 files changed, 455 insertions(+), 403 deletions(-) create mode 100644 src/handlers/project/add/memory/index.test.ts diff --git a/src/handlers/project/add/memory/index.test.ts b/src/handlers/project/add/memory/index.test.ts new file mode 100644 index 000000000..a3d83e8bf --- /dev/null +++ b/src/handlers/project/add/memory/index.test.ts @@ -0,0 +1,454 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../../testing"; +import { InputValidationError } from "../../../../errors"; +import { MEMORY_DESCRIPTION_MAX_LENGTH } from "../../../../projectSchemas/memory"; + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-memory-")); + tempDirectories.push(directory); + // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var + // symlink), matching the paths the manager derives from process.cwd(). + process.chdir(directory); + return process.cwd(); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function run(args: string[], opts?: { core?: TestCoreClient }) { + const io = testIO(); + const core = opts?.core ?? new TestCoreClient(); + const root = createRootHandler(core, { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return { io, core }; +} + +/** Scaffolds a project and cds into it so withProject resolves it. */ +async function inProject(name = "TestProject"): Promise { + const directory = await inTempDirectory(); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; +} + +describe("project add memory", () => { + /** Verify the flag -> agentcore.json memories[] entry for each flag. */ + test.each<[string, string[], Record]>([ + [ + "minimal — name only", + ["--name", "x"], + { name: "x", eventExpiryDuration: 30, strategies: [] }, + ], + [ + "description", + ["--name", "x", "--description", "Durable facts and preferences for each end user."], + { description: "Durable facts and preferences for each end user." }, + ], + [ + "description — at the maximum length", + ["--name", "x", "--description", "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH)], + { description: "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH) }, + ], + [ + "event-expiry-duration", + ["--name", "x", "--event-expiry-duration", "7"], + { eventExpiryDuration: 7 }, + ], + [ + "strategies — shorthand, one type", + ["--name", "x", "--strategies", "SEMANTIC"], + { strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }] }, + ], + [ + "strategies — shorthand, several types with surrounding whitespace", + ["--name", "x", "--strategies", "SEMANTIC, SUMMARIZATION ,USER_PREFERENCE"], + { + strategies: [ + { type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }, + { type: "SUMMARIZATION", namespaceTemplates: ["/summaries/{actorId}/{sessionId}"] }, + { type: "USER_PREFERENCE", namespaceTemplates: ["/users/{actorId}/preferences"] }, + ], + }, + ], + [ + "strategies — shorthand EPISODIC also gets the default reflection namespaces", + ["--name", "x", "--strategies", "EPISODIC"], + { + strategies: [ + { + type: "EPISODIC", + namespaceTemplates: ["/episodes/{actorId}/{sessionId}"], + reflectionNamespaceTemplates: ["/episodes/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON semanticMemoryStrategy", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","description":"durable facts","namespaceTemplates":["/orgs/{actorId}"]}}]', + ], + { + strategies: [ + { + type: "SEMANTIC", + name: "facts", + description: "durable facts", + namespaceTemplates: ["/orgs/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON summaryMemoryStrategy maps to SUMMARIZATION", + ["--name", "x", "--strategies", '[{"summaryMemoryStrategy":{"name":"summaries"}}]'], + { strategies: [{ type: "SUMMARIZATION", name: "summaries" }] }, + ], + [ + "strategies — JSON userPreferenceMemoryStrategy maps to USER_PREFERENCE", + ["--name", "x", "--strategies", '[{"userPreferenceMemoryStrategy":{"name":"prefs"}}]'], + { strategies: [{ type: "USER_PREFERENCE", name: "prefs" }] }, + ], + [ + "strategies — JSON episodicMemoryStrategy hoists reflectionConfiguration", + [ + "--name", + "x", + "--strategies", + '[{"episodicMemoryStrategy":{"name":"episodes","namespaceTemplates":["/episodes/{actorId}/{sessionId}"],"reflectionConfiguration":{"namespaceTemplates":["/episodes/{actorId}"]}}}]', + ], + { + strategies: [ + { + type: "EPISODIC", + name: "episodes", + namespaceTemplates: ["/episodes/{actorId}/{sessionId}"], + reflectionNamespaceTemplates: ["/episodes/{actorId}"], + }, + ], + }, + ], + [ + "strategies — JSON deprecated namespaces are preserved", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"legacy","namespaces":["/legacy"]}}]', + ], + { strategies: [{ type: "SEMANTIC", name: "legacy", namespaces: ["/legacy"] }] }, + ], + [ + "indexed-keys", + [ + "--name", + "x", + "--strategies", + "SEMANTIC", + "--indexed-keys", + '[{"key":"tenant","type":"STRING"},{"key":"score","type":"NUMBER"}]', + ], + { + indexedKeys: [ + { key: "tenant", type: "STRING" }, + { key: "score", type: "NUMBER" }, + ], + }, + ], + [ + "stream-delivery-resources", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}', + ], + { + streamDeliveryResources: { + resources: [ + { + kinesis: { + dataStreamArn: "arn:aws:kinesis:us-east-1:123456789012:stream/s", + contentConfigurations: [{ type: "MEMORY_RECORDS", level: "FULL_CONTENT" }], + }, + }, + ], + }, + }, + ], + [ + "encryption-key-arn and execution-role-arn", + [ + "--name", + "x", + "--encryption-key-arn", + "arn:aws:kms:us-east-1:123456789012:key/abc", + "--execution-role-arn", + "arn:aws:iam::123456789012:role/MyMemoryRole", + ], + { + encryptionKeyArn: "arn:aws:kms:us-east-1:123456789012:key/abc", + executionRoleArn: "arn:aws:iam::123456789012:role/MyMemoryRole", + }, + ], + ["tags", ["--name", "x", "--tags", '{"team":"ml"}'], { tags: { team: "ml" } }], + ])("%s", async (_label, flags, expected) => { + const projectRoot = await inProject(); + await run(["add", "memory", ...flags]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(agentcoreJson.memories).toHaveLength(1); + expect(agentcoreJson.memories[0]).toMatchObject(expected); + }); + + test("adds no files under app/", async () => { + const projectRoot = await inProject(); + await run(["add", "memory", "--name", "x"]); + + expect(existsSync(join(projectRoot, "app", "x"))).toBe(false); + }); + + test("rejects a duplicate memory name", async () => { + await inProject(); + await run(["add", "memory", "--name", "x"]); + await expect(run(["add", "memory", "--name", "x"])).rejects.toBeInstanceOf( + InputValidationError, + ); + }); + + test.each([ + ["missing --name", ["--event-expiry-duration", "30"]], + ["invalid name", ["--name", "1bad"]], + ["empty description", ["--name", "x", "--description", ""]], + [ + "description above the maximum length", + ["--name", "x", "--description", "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH + 1)], + ], + ["event-expiry-duration below the minimum", ["--name", "x", "--event-expiry-duration", "2"]], + ["event-expiry-duration above the maximum", ["--name", "x", "--event-expiry-duration", "400"]], + ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], + ["empty shorthand strategy entry", ["--name", "x", "--strategies", "SEMANTIC,"]], + ["duplicate shorthand strategy", ["--name", "x", "--strategies", "SEMANTIC,SEMANTIC"]], + ["unrecognized JSON strategy variant", ["--name", "x", "--strategies", '[{"unknown":{}}]']], + [ + "JSON strategy without its required name", + ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{}}]'], + ], + [ + "JSON strategy input must be an array", + ["--name", "x", "--strategies", '{"semanticMemoryStrategy":{"name":"facts"}}'], + ], + // CUSTOM is rejected in both forms until a custom strategy's extraction + // configuration can be expressed. See aws/agentcore-cli#241, #266, #713, #676. + ["CUSTOM shorthand strategy", ["--name", "x", "--strategies", "CUSTOM"]], + [ + "customMemoryStrategy JSON variant", + ["--name", "x", "--strategies", '[{"customMemoryStrategy":{"name":"c"}}]'], + ], + [ + "episodic strategy without reflection namespaces", + ["--name", "x", "--strategies", '[{"episodicMemoryStrategy":{"name":"episodes"}}]'], + ], + [ + "namespaces and namespaceTemplates are mutually exclusive", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","namespaces":["/a"],"namespaceTemplates":["/b"]}}]', + ], + ], + [ + "indexed-keys without a strategy", + ["--name", "x", "--indexed-keys", '[{"key":"tenant","type":"STRING"}]'], + ], + [ + "indexed-keys with an unsupported type", + [ + "--name", + "x", + "--strategies", + "SEMANTIC", + "--indexed-keys", + '[{"key":"tenant","type":"BOOLEAN"}]', + ], + ], + [ + "indexed-keys without a key", + ["--name", "x", "--strategies", "SEMANTIC", "--indexed-keys", '[{"type":"STRING"}]'], + ], + [ + "unrecognized stream delivery resource variant", + ["--name", "x", "--stream-delivery-resources", '{"resources":[{"firehose":{}}]}'], + ], + [ + "stream delivery resource without a dataStreamArn", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}', + ], + ], + [ + "stream delivery content configuration without a level", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS"}]}}]}', + ], + ], + [ + "stream delivery content configuration with an unsupported type", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"EVENTS","level":"FULL_CONTENT"}]}}]}', + ], + ], + ["malformed --strategies JSON", ["--name", "x", "--strategies", "[{"]], + ])("%s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "memory", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); + + test.each<[string, string[], RegExp]>([ + [ + "rejects multiple strategy union members", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts"},"summaryMemoryStrategy":{"name":"summaries"}}]', + ], + /Exactly one memory strategy member must be specified; received 2/, + ], + [ + "rejects unsupported strategy fields", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","memoryRecordSchema":{}}}]', + ], + /memory strategy field 'memoryRecordSchema' is not supported by project memory resources/, + ], + [ + "rejects unsupported episodic reflection fields", + [ + "--name", + "x", + "--strategies", + '[{"episodicMemoryStrategy":{"name":"episodes","namespaceTemplates":["/episodes/{actorId}/{sessionId}"],"reflectionConfiguration":{"namespaceTemplates":["/episodes/{actorId}"],"memoryRecordSchema":{}}}}]', + ], + /episodic reflection configuration field 'memoryRecordSchema' is not supported by project memory resources/, + ], + [ + "rejects prototype-named unsupported strategy fields", + [ + "--name", + "x", + "--strategies", + '[{"semanticMemoryStrategy":{"name":"facts","__proto__":{"polluted":true}}}]', + ], + /memory strategy field '__proto__' is not supported by project memory resources/, + ], + [ + "validates indexed-keys as an array", + ["--name", "x", "--indexed-keys", '{"key":"tenant","type":"STRING"}'], + /Invalid value for option '--indexed-keys'/, + ], + [ + "rejects unsupported indexed-key fields", + [ + "--name", + "x", + "--strategies", + "SEMANTIC", + "--indexed-keys", + '[{"key":"tenant","type":"STRING","unexpected":true}]', + ], + /indexed key field 'unexpected' is not supported by project memory resources/, + ], + [ + "validates stream delivery resources as an array", + ["--name", "x", "--stream-delivery-resources", '{"resources":{}}'], + /Invalid value for option '--stream-delivery-resources'/, + ], + [ + "rejects unsupported top-level stream delivery fields", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}],"unexpected":true}', + ], + /stream delivery resources field 'unexpected' is not supported by project memory resources/, + ], + [ + "rejects unsupported stream delivery resource variants", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]},"firehose":{}}]}', + ], + /stream delivery resource field 'firehose' is not supported by project memory resources/, + ], + [ + "rejects unsupported Kinesis stream delivery fields", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}],"unexpected":true}}]}', + ], + /Kinesis stream delivery resource field 'unexpected' is not supported by project memory resources/, + ], + [ + "rejects unsupported nested stream delivery fields", + [ + "--name", + "x", + "--stream-delivery-resources", + '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT","unexpected":true}]}}]}', + ], + /stream content configuration field 'unexpected' is not supported by project memory resources/, + ], + [ + "validates tags as a string map", + ["--name", "x", "--tags", '["team=ml"]'], + /Invalid value for option '--tags'/, + ], + ])("%s", async (_label, flags, error) => { + await inProject(); + await expect(run(["add", "memory", ...flags])).rejects.toThrow(error); + }); +}); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d5fd37914..ced2d9277 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,9 +10,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; -import { DeserializationError, InputValidationError } from "../../errors"; -import { FsReadWriteJson, type ReadWriteJson } from "../../io"; -import { MEMORY_DESCRIPTION_MAX_LENGTH } from "../../projectSchemas/memory"; +import { InputValidationError } from "../../errors"; async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -303,406 +301,6 @@ describe("project add config-bundle", () => { }); }); -describe("project add memory", () => { - /** Verify the flag -> agentcore.json memories[] entry for each flag. */ - test.each<[string, string[], Record]>([ - [ - "minimal — name only", - ["--name", "x"], - { name: "x", eventExpiryDuration: 30, strategies: [] }, - ], - [ - "description", - ["--name", "x", "--description", "Durable facts and preferences for each end user."], - { description: "Durable facts and preferences for each end user." }, - ], - [ - "description — at the maximum length", - ["--name", "x", "--description", "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH)], - { description: "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH) }, - ], - [ - "event-expiry-duration", - ["--name", "x", "--event-expiry-duration", "7"], - { eventExpiryDuration: 7 }, - ], - [ - "strategies — shorthand, one type", - ["--name", "x", "--strategies", "SEMANTIC"], - { strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }] }, - ], - [ - "strategies — shorthand, several types with surrounding whitespace", - ["--name", "x", "--strategies", "SEMANTIC, SUMMARIZATION ,USER_PREFERENCE"], - { - strategies: [ - { type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }, - { type: "SUMMARIZATION", namespaceTemplates: ["/summaries/{actorId}/{sessionId}"] }, - { type: "USER_PREFERENCE", namespaceTemplates: ["/users/{actorId}/preferences"] }, - ], - }, - ], - [ - "strategies — shorthand EPISODIC also gets the default reflection namespaces", - ["--name", "x", "--strategies", "EPISODIC"], - { - strategies: [ - { - type: "EPISODIC", - namespaceTemplates: ["/episodes/{actorId}/{sessionId}"], - reflectionNamespaceTemplates: ["/episodes/{actorId}"], - }, - ], - }, - ], - [ - "strategies — JSON semanticMemoryStrategy", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"facts","description":"durable facts","namespaceTemplates":["/orgs/{actorId}"]}}]', - ], - { - strategies: [ - { - type: "SEMANTIC", - name: "facts", - description: "durable facts", - namespaceTemplates: ["/orgs/{actorId}"], - }, - ], - }, - ], - [ - "strategies — JSON summaryMemoryStrategy maps to SUMMARIZATION", - ["--name", "x", "--strategies", '[{"summaryMemoryStrategy":{"name":"summaries"}}]'], - { strategies: [{ type: "SUMMARIZATION", name: "summaries" }] }, - ], - [ - "strategies — JSON userPreferenceMemoryStrategy maps to USER_PREFERENCE", - ["--name", "x", "--strategies", '[{"userPreferenceMemoryStrategy":{"name":"prefs"}}]'], - { strategies: [{ type: "USER_PREFERENCE", name: "prefs" }] }, - ], - [ - "strategies — JSON episodicMemoryStrategy hoists reflectionConfiguration", - [ - "--name", - "x", - "--strategies", - '[{"episodicMemoryStrategy":{"name":"episodes","namespaceTemplates":["/episodes/{actorId}/{sessionId}"],"reflectionConfiguration":{"namespaceTemplates":["/episodes/{actorId}"]}}}]', - ], - { - strategies: [ - { - type: "EPISODIC", - name: "episodes", - namespaceTemplates: ["/episodes/{actorId}/{sessionId}"], - reflectionNamespaceTemplates: ["/episodes/{actorId}"], - }, - ], - }, - ], - [ - "strategies — JSON deprecated namespaces are preserved", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"legacy","namespaces":["/legacy"]}}]', - ], - { strategies: [{ type: "SEMANTIC", name: "legacy", namespaces: ["/legacy"] }] }, - ], - [ - "indexed-keys", - [ - "--name", - "x", - "--strategies", - "SEMANTIC", - "--indexed-keys", - '[{"key":"tenant","type":"STRING"},{"key":"score","type":"NUMBER"}]', - ], - { - indexedKeys: [ - { key: "tenant", type: "STRING" }, - { key: "score", type: "NUMBER" }, - ], - }, - ], - [ - "stream-delivery-resources", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}', - ], - { - streamDeliveryResources: { - resources: [ - { - kinesis: { - dataStreamArn: "arn:aws:kinesis:us-east-1:123456789012:stream/s", - contentConfigurations: [{ type: "MEMORY_RECORDS", level: "FULL_CONTENT" }], - }, - }, - ], - }, - }, - ], - [ - "encryption-key-arn and execution-role-arn", - [ - "--name", - "x", - "--encryption-key-arn", - "arn:aws:kms:us-east-1:123456789012:key/abc", - "--execution-role-arn", - "arn:aws:iam::123456789012:role/MyMemoryRole", - ], - { - encryptionKeyArn: "arn:aws:kms:us-east-1:123456789012:key/abc", - executionRoleArn: "arn:aws:iam::123456789012:role/MyMemoryRole", - }, - ], - ["tags", ["--name", "x", "--tags", '{"team":"ml"}'], { tags: { team: "ml" } }], - ])("%s", async (_label, flags, expected) => { - const projectRoot = await inProject(); - await run(["add", "memory", ...flags]); - - const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); - expect(agentcoreJson.memories).toHaveLength(1); - expect(agentcoreJson.memories[0]).toMatchObject(expected); - }); - - test("adds no files under app/", async () => { - const projectRoot = await inProject(); - await run(["add", "memory", "--name", "x"]); - - expect(existsSync(join(projectRoot, "app", "x"))).toBe(false); - }); - - test("rejects a duplicate memory name", async () => { - await inProject(); - await run(["add", "memory", "--name", "x"]); - await expect(run(["add", "memory", "--name", "x"])).rejects.toBeInstanceOf( - InputValidationError, - ); - }); - - test.each([ - ["missing --name", ["--event-expiry-duration", "30"]], - ["invalid name", ["--name", "1bad"]], - ["empty description", ["--name", "x", "--description", ""]], - [ - "description above the maximum length", - ["--name", "x", "--description", "a".repeat(MEMORY_DESCRIPTION_MAX_LENGTH + 1)], - ], - ["event-expiry-duration below the minimum", ["--name", "x", "--event-expiry-duration", "2"]], - ["event-expiry-duration above the maximum", ["--name", "x", "--event-expiry-duration", "400"]], - ["unrecognized shorthand strategy", ["--name", "x", "--strategies", "NONSENSE"]], - ["empty shorthand strategy entry", ["--name", "x", "--strategies", "SEMANTIC,"]], - ["duplicate shorthand strategy", ["--name", "x", "--strategies", "SEMANTIC,SEMANTIC"]], - ["unrecognized JSON strategy variant", ["--name", "x", "--strategies", '[{"unknown":{}}]']], - [ - "JSON strategy without its required name", - ["--name", "x", "--strategies", '[{"semanticMemoryStrategy":{}}]'], - ], - [ - "JSON strategy input must be an array", - ["--name", "x", "--strategies", '{"semanticMemoryStrategy":{"name":"facts"}}'], - ], - // CUSTOM is rejected in both forms until a custom strategy's extraction - // configuration can be expressed. See aws/agentcore-cli#241, #266, #713, #676. - ["CUSTOM shorthand strategy", ["--name", "x", "--strategies", "CUSTOM"]], - [ - "customMemoryStrategy JSON variant", - ["--name", "x", "--strategies", '[{"customMemoryStrategy":{"name":"c"}}]'], - ], - [ - "episodic strategy without reflection namespaces", - ["--name", "x", "--strategies", '[{"episodicMemoryStrategy":{"name":"episodes"}}]'], - ], - [ - "namespaces and namespaceTemplates are mutually exclusive", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"facts","namespaces":["/a"],"namespaceTemplates":["/b"]}}]', - ], - ], - [ - "indexed-keys without a strategy", - ["--name", "x", "--indexed-keys", '[{"key":"tenant","type":"STRING"}]'], - ], - [ - "indexed-keys with an unsupported type", - [ - "--name", - "x", - "--strategies", - "SEMANTIC", - "--indexed-keys", - '[{"key":"tenant","type":"BOOLEAN"}]', - ], - ], - [ - "indexed-keys without a key", - ["--name", "x", "--strategies", "SEMANTIC", "--indexed-keys", '[{"type":"STRING"}]'], - ], - [ - "unrecognized stream delivery resource variant", - ["--name", "x", "--stream-delivery-resources", '{"resources":[{"firehose":{}}]}'], - ], - [ - "stream delivery resource without a dataStreamArn", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}]}', - ], - ], - [ - "stream delivery content configuration without a level", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS"}]}}]}', - ], - ], - [ - "stream delivery content configuration with an unsupported type", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"EVENTS","level":"FULL_CONTENT"}]}}]}', - ], - ], - ["malformed --strategies JSON", ["--name", "x", "--strategies", "[{"]], - ])("%s", async (_label, flags) => { - await inProject(); - await expect(run(["add", "memory", ...flags])).rejects.toBeInstanceOf(InputValidationError); - }); - - test.each<[string, string[], RegExp]>([ - [ - "rejects multiple strategy union members", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"facts"},"summaryMemoryStrategy":{"name":"summaries"}}]', - ], - /Exactly one memory strategy member must be specified; received 2/, - ], - [ - "rejects unsupported strategy fields", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"facts","memoryRecordSchema":{}}}]', - ], - /memory strategy field 'memoryRecordSchema' is not supported by project memory resources/, - ], - [ - "rejects unsupported episodic reflection fields", - [ - "--name", - "x", - "--strategies", - '[{"episodicMemoryStrategy":{"name":"episodes","namespaceTemplates":["/episodes/{actorId}/{sessionId}"],"reflectionConfiguration":{"namespaceTemplates":["/episodes/{actorId}"],"memoryRecordSchema":{}}}}]', - ], - /episodic reflection configuration field 'memoryRecordSchema' is not supported by project memory resources/, - ], - [ - "rejects prototype-named unsupported strategy fields", - [ - "--name", - "x", - "--strategies", - '[{"semanticMemoryStrategy":{"name":"facts","__proto__":{"polluted":true}}}]', - ], - /memory strategy field '__proto__' is not supported by project memory resources/, - ], - [ - "validates indexed-keys as an array", - ["--name", "x", "--indexed-keys", '{"key":"tenant","type":"STRING"}'], - /Invalid value for option '--indexed-keys'/, - ], - [ - "rejects unsupported indexed-key fields", - [ - "--name", - "x", - "--strategies", - "SEMANTIC", - "--indexed-keys", - '[{"key":"tenant","type":"STRING","unexpected":true}]', - ], - /indexed key field 'unexpected' is not supported by project memory resources/, - ], - [ - "validates stream delivery resources as an array", - ["--name", "x", "--stream-delivery-resources", '{"resources":{}}'], - /Invalid value for option '--stream-delivery-resources'/, - ], - [ - "rejects unsupported top-level stream delivery fields", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]}}],"unexpected":true}', - ], - /stream delivery resources field 'unexpected' is not supported by project memory resources/, - ], - [ - "rejects unsupported stream delivery resource variants", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}]},"firehose":{}}]}', - ], - /stream delivery resource field 'firehose' is not supported by project memory resources/, - ], - [ - "rejects unsupported Kinesis stream delivery fields", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT"}],"unexpected":true}}]}', - ], - /Kinesis stream delivery resource field 'unexpected' is not supported by project memory resources/, - ], - [ - "rejects unsupported nested stream delivery fields", - [ - "--name", - "x", - "--stream-delivery-resources", - '{"resources":[{"kinesis":{"dataStreamArn":"arn:aws:kinesis:us-east-1:123456789012:stream/s","contentConfigurations":[{"type":"MEMORY_RECORDS","level":"FULL_CONTENT","unexpected":true}]}}]}', - ], - /stream content configuration field 'unexpected' is not supported by project memory resources/, - ], - [ - "validates tags as a string map", - ["--name", "x", "--tags", '["team=ml"]'], - /Invalid value for option '--tags'/, - ], - ])("%s", async (_label, flags, error) => { - await inProject(); - await expect(run(["add", "memory", ...flags])).rejects.toThrow(error); - }); -}); - describe("project build", () => { async function inBuildableProject(): Promise { const projectRoot = await inProject("MyAgent");