diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 4a426ef1f..40abbd13e 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -175,6 +175,7 @@ export class FsProjectManager implements ProjectManager { case "config-bundle": case "online-eval": case "online-insight": + case "memory": newResources.push(resourceConfig); break; @@ -312,5 +313,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.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/add/memory/index.ts b/src/handlers/project/add/memory/index.ts new file mode 100644 index 000000000..e6776b998 --- /dev/null +++ b/src/handlers/project/add/memory/index.ts @@ -0,0 +1,312 @@ +import z from "zod"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; +import { parseJsonFlagWithSchema } from "../../../utils"; +import { InputValidationError } from "../../../../errors"; +import { + DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, + DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, + IndexedKeySchema, + MemoryStrategyNameSchema, + MemoryStrategyTypeSchema, + StreamContentLevelSchema, + type MemoryStrategy, +} 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, + 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 + .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({ + code: "custom", + path: [field], + message: `${label} field '${field}' is not supported by project memory resources`, + }); + } + } + }) + .pipe(z.object(shape)); +} + +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 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. + +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 example: + [ + { + "semanticMemoryStrategy": { + "name": "facts", + "description": "Durable user facts", + "namespaceTemplates": ["/users/{actorId}/facts"] + } + }, + { + "episodicMemoryStrategy": { + "name": "episodes", + "namespaceTemplates": ["/episodes/{actorId}/{sessionId}"], + "reflectionConfiguration": { + "namespaceTemplates": ["/episodes/{actorId}"] + } + } + } + ]`; + +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("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)", + 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 = parseJsonFlagWithSchema( + "indexed-keys", + flags["indexed-keys"], + z.array(IndexedKeyInputSchema), + ); + const inputStreamDelivery = parseJsonFlagWithSchema( + "stream-delivery-resources", + flags["stream-delivery-resources"], + StreamDeliveryResourcesInputSchema, + ); + + const memoryConfig = { + name: flags.name, + description: flags["description"], + eventExpiryDuration: flags["event-expiry-duration"], + strategies: flags["strategies"] ? toStrategies(flags["strategies"]) : undefined, + indexedKeys: inputIndexedKeys, + encryptionKeyArn: flags["encryption-key-arn"], + executionRoleArn: flags["execution-role-arn"], + streamDeliveryResources: inputStreamDelivery, + tags: parseJsonFlagWithSchema("tags", flags["tags"], TagsSchema), + }; + + 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 JSON container + * selects the JSON form; anything else is read as the shorthand. + */ +function toStrategies(raw: string): MemoryStrategy[] { + const trimmed = raw.trimStart(); + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + const inputs = + parseJsonFlagWithSchema("strategies", raw, z.array(MemoryStrategyInputSchema)) ?? []; + return inputs.map(toStrategy); + } + 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. */ +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: ProjectMemoryStrategyInput): MemoryStrategy { + if (strategy.semanticMemoryStrategy) + return { type: "SEMANTIC", ...toProjectStrategyFields(strategy.semanticMemoryStrategy) }; + if (strategy.summaryMemoryStrategy) + return { type: "SUMMARIZATION", ...toProjectStrategyFields(strategy.summaryMemoryStrategy) }; + if (strategy.userPreferenceMemoryStrategy) + return { + type: "USER_PREFERENCE", + ...toProjectStrategyFields(strategy.userPreferenceMemoryStrategy), + }; + if (strategy.episodicMemoryStrategy) { + const c = strategy.episodicMemoryStrategy; + return { + type: "EPISODIC", + ...toProjectStrategyFields(c), + reflectionNamespaceTemplates: c.reflectionConfiguration?.namespaceTemplates, + reflectionNamespaces: c.reflectionConfiguration?.namespaces, + }; + } + throw new InputValidationError("Unrecognized memory strategy variant"); +} + +/** + * Picks only the fields the project schema supports from an SDK strategy variant. + * The JSON input schema rejects unsupported fields before this conversion. + */ +function toProjectStrategyFields(strategy: { + name?: string; + description?: string; + namespaces?: string[]; + namespaceTemplates?: string[]; +}) { + return { + name: strategy.name, + description: strategy.description, + namespaceTemplates: strategy.namespaceTemplates, + namespaces: strategy.namespaces, + }; +} 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"]; 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)