Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import os
import uuid
from typing import Optional

from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryStrategies.length}}, RetrievalConfig{{/if}}
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager

MEMORY_ID = os.getenv("{{memoryEnvVarName}}")
REGION = os.getenv("AWS_REGION")


def get_memory_session_manager(
session_id: Optional[str], actor_id: str
) -> Optional[AgentCoreMemorySessionManager]:
if not MEMORY_ID:
return None

session_id = session_id or uuid.uuid4().hex

{{#if memoryStrategies.length}}
retrieval_config = {
{{#if (includes memoryStrategies "SEMANTIC")}}
f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5),
{{/if}}
{{#if (includes memoryStrategies "USER_PREFERENCE")}}
f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5),
{{/if}}
{{#if (includes memoryStrategies "EPISODIC")}}
f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5),
{{/if}}
{{#if (includes memoryStrategies "SUMMARIZATION")}}
f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5),
{{/if}}
}
{{/if}}

return AgentCoreMemorySessionManager(
AgentCoreMemoryConfig(
memory_id=MEMORY_ID,
session_id=session_id,
actor_id=actor_id,
{{#if memoryStrategies.length}}
retrieval_config=retrieval_config,
{{/if}}
),
REGION,
)
37 changes: 37 additions & 0 deletions src/core/project/__snapshots__/manager.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,49 @@ exports[`FsProjectManager.create snapshots the Strands project manifest and runt
"app/strands_agent/main.py",
"app/strands_agent/mcp_client/__init__.py",
"app/strands_agent/mcp_client/client.py",
"app/strands_agent/memory/__init__.py",
"app/strands_agent/memory/session.py",
"app/strands_agent/model/__init__.py",
"app/strands_agent/model/load.py",
"app/strands_agent/model/mantle_compat.py",
"app/strands_agent/pyproject.toml",
"app/strands_agent/skills/fetcher.py",
],
"memories": [
{
"eventExpiryDuration": 30,
"name": "strands_agentMemory",
"strategies": [
{
"namespaceTemplates": [
"/users/{actorId}/facts",
],
"type": "SEMANTIC",
},
{
"namespaceTemplates": [
"/users/{actorId}/preferences",
],
"type": "USER_PREFERENCE",
},
{
"namespaceTemplates": [
"/summaries/{actorId}/{sessionId}",
],
"type": "SUMMARIZATION",
},
{
"namespaceTemplates": [
"/episodes/{actorId}/{sessionId}",
],
"reflectionNamespaceTemplates": [
"/episodes/{actorId}",
],
"type": "EPISODIC",
},
],
},
],
"runtimes": [
{
"build": "CodeZip",
Expand Down
9 changes: 5 additions & 4 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { DeserializationError, ProjectStateError } from "../../errors/errors";
import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { FsProjectManager } from "./manager";
import { resolveRuntimeTemplateShortcut } from "../../handlers/project/shortcuts";
import {
RUNTIME_TEMPLATE_SHORTCUTS,
type CreateProjectInput,
type DeployResult,
type Project,
Expand All @@ -17,9 +17,9 @@ import {
import { createSilentLogger } from "../../testing";
import type { DeployBackendInput, ProjectBackend } from "./backends/types";

const HELLO_WORLD_PYTHON = RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python"];
const HELLO_WORLD_PYTHON_CONTAINER = RUNTIME_TEMPLATE_SHORTCUTS["hello-world-python-container"];
const STRANDS_PYTHON = RUNTIME_TEMPLATE_SHORTCUTS["strands-python"];
const HELLO_WORLD_PYTHON = resolveRuntimeTemplateShortcut("hello-world-python");
const HELLO_WORLD_PYTHON_CONTAINER = resolveRuntimeTemplateShortcut("hello-world-python-container");
const STRANDS_PYTHON = resolveRuntimeTemplateShortcut("strands-python");

const originalCwd = process.cwd();
const tempDirectories: string[] = [];
Expand Down Expand Up @@ -102,6 +102,7 @@ describe("FsProjectManager.create", () => {
expect({
manifest: await projectManifest(projectRoot),
runtimes: spec.runtimes,
memories: spec.memories,
}).toMatchSnapshot();
});

Expand Down
29 changes: 28 additions & 1 deletion src/core/project/templates/fsTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ describe("FsTreeNode.fromAssetSource", () => {
},
};

const tree = await FsTreeNode.fromAssetSource(source, "template", "root");
const tree = await FsTreeNode.fromAssetSource(
{ assetSource: source },
{ assetDir: "template" },
{ rootDirName: "root" },
);

expect(tree.name).toBe("root");
expect(tree.children.map((node) => node.name)).toEqual(["README.md", "src", ".gitignore"]);
Expand All @@ -83,4 +87,27 @@ describe("FsTreeNode.fromAssetSource", () => {
]);
expect(await tree.children[2]?.bytes?.()).toBe("contents:template/gitignore.template");
});

test("transforms content and filters files and directories", async () => {
const source: AssetSource = {
async list() {
return ["template/keep.txt", "template/skip.txt", "template/optional/nested.txt"];
},
async read(assetPath) {
return `contents:${assetPath}`;
},
};

const tree = await FsTreeNode.fromAssetSource(
{ assetSource: source },
{ assetDir: "template" },
{
transformContent: (content) => content.toUpperCase(),
filter: (name, isDir) => name !== "skip.txt" && !(isDir && name === "optional"),
},
);

expect(tree.children.map(({ name }) => name)).toEqual(["keep.txt"]);
expect(await tree.children[0]?.bytes?.()).toBe("CONTENTS:TEMPLATE/KEEP.TXT");
});
});
52 changes: 35 additions & 17 deletions src/core/project/templates/fsTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,30 @@ export class FsTreeNode {
}

/**
* Expands the flat asset listing under assetDir into a nested tree of nodes.
* Builds a file tree from assets under `input.assetDir`.
*
* @param config - Asset source configuration.
* @param input - Asset directory to load.
* @param options - Optional root name, lazy content transform, and descendant filter. Rejecting a directory omits its subtree.
*/
static async fromAssetSource(
src: AssetSource,
assetDir: string,
rootDirName?: string,
transform?: (content: string) => string,
config: { assetSource: AssetSource },
input: { assetDir: string },
options?: {
rootDirName?: string;
transformContent?: (content: string) => string;
filter?: (name: string, isDir: boolean) => boolean;
},
): Promise<FsTreeNode> {
const paths = await src.list(assetDir);
const { assetSource } = config;
const { assetDir } = input;
const rootDirName = options?.rootDirName;
const transformContent = options?.transformContent;
const filter = options?.filter;
const paths = await assetSource.list(assetDir);
const root = FsTreeNode.createDirectory(rootDirName ?? assetDir, []);

for (const assetPath of paths) {
assetPaths: for (const assetPath of paths) {
const relative = assetPath.slice(assetDir.length + 1);
const segments = relative.split("/");
if (segments.some((s) => s === "" || s === "." || s === "..")) {
Expand All @@ -92,25 +104,31 @@ export class FsTreeNode {
}

let parent = root;
segments.forEach((segment, index) => {
if (index === segments.length - 1) {
for (const [index, segment] of segments.entries()) {
const isDir = index < segments.length - 1;
const name = isDir ? segment : renderName(segment);
// if the segment of a path rejects, reject the rest of the path so we jump to top-loop via assetPaths label.
if (filter && !filter(name, isDir)) continue assetPaths;

if (!isDir) {
parent.children.push(
FsTreeNode.createFile(renderName(segment), async () => {
const raw = await src.read(assetPath);
return transform ? transform(raw) : raw;
FsTreeNode.createFile(name, async () => {
const raw = await assetSource.read(assetPath);
return transformContent ? transformContent(raw) : raw;
}),
);
return;
continue;
}

let child = parent.children.find((n): n is FsTreeNode => n.isDir && n.name === segment);
let child = parent.children.find(
(node): node is FsTreeNode => node.isDir && node.name === name,
);
if (!child) {
child = FsTreeNode.createDirectory(segment, []);
child = FsTreeNode.createDirectory(name, []);
parent.children.push(child);
}

parent = child;
});
}
}

return root;
Expand Down
2 changes: 1 addition & 1 deletion src/core/project/templates/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function createProjectTree(
config.assetSource.read("templates/shared/gitignore.template"),
),
FsTreeNode.createDirectory("agentcore", [
await FsTreeNode.fromAssetSource(config.assetSource, "cdk"),
await FsTreeNode.fromAssetSource({ assetSource: config.assetSource }, { assetDir: "cdk" }),
FsTreeNode.createFile("agentcore.json", async () =>
json({
name: input.projectName,
Expand Down
35 changes: 24 additions & 11 deletions src/core/project/templates/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa
if (input.protocol !== undefined && input.protocol !== "HTTP")
throw new InputValidationError(`hello-world-python only supports HTTP protocol`);
const tree = await FsTreeNode.fromAssetSource(
assetSource,
input.scaffoldRuntimeInput.build === "Container"
? "templates/hello-world-python-container"
: "templates/hello-world-python",
input.name,
{ assetSource },
{
assetDir:
input.scaffoldRuntimeInput.build === "Container"
? "templates/hello-world-python-container"
: "templates/hello-world-python",
},
{ rootDirName: input.name },
);
return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } };
},
Expand All @@ -87,10 +90,14 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa
? [{ mountPath: configuration.s3FilesAccessPoint.mountPath }]
: [],
);
const memory = input.scaffoldRuntimeInput.memory;
const context = {
name: toPythonPackageName(input.name),
modelProvider: input.scaffoldRuntimeInput.modelProvider,
hasMemory: input.scaffoldRuntimeInput.memory !== "none",
hasMemory: memory !== undefined,
// the CDK injects this env var corresponding to the actual ID once its resolved on deployment.
memoryEnvVarName: memory ? `MEMORY_${memory.name.toUpperCase()}_ID` : undefined,
memoryStrategies: memory?.strategies.map(({ type }) => type) ?? [],
hasIdentity: false,
hasGateway: false,
hasPayment: false,
Expand All @@ -105,14 +112,20 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa
hasConfigBundle: false,
};
const tree = await FsTreeNode.fromAssetSource(
assetSource,
"templates/strands-http-python",
input.name,
(raw) => templateRenderer.render(raw, context),
{ assetSource },
{ assetDir: "templates/strands-http-python" },
{
rootDirName: input.name,
transformContent: (raw) => templateRenderer.render(raw, context),
filter: (name, isDir) => memory !== undefined || !isDir || name !== "memory",
},
);
return {
tree,
spec: { runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }] },
spec: {
runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }],
...(memory && { memories: [memory] }),
},
};
},
});
Expand Down
55 changes: 54 additions & 1 deletion src/handlers/project/add/runtime/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,58 @@ describe("project add runtime", () => {
expect(runtime.runtimeVersion).toBe(isContainer ? undefined : "PYTHON_3_14");
});

test.each([
["default", [], ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"]],
["none", ["--memory", "none"], []],
["short", ["--memory", "shortTerm"], []],
[
"longAndShortTerm",
["--memory", "longAndShortTerm"],
["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"],
],
])("custom strands %s memory", async (_label, memoryFlags, expectedStrategies) => {
const projectRoot = await inProject();
await run([
"add",
"runtime",
"--name",
"my_agent",
"--build",
"CodeZip",
"--language",
"Python",
"--framework",
"strands",
"--model-provider",
"Bedrock",
...memoryFlags,
]);

const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json();
const memory = spec.memories.find(
(candidate: { name: string }) => candidate.name === "my_agentMemory",
);

if (memoryFlags.length > 1 && memoryFlags[1] === "none") {
expect(memory).toBeUndefined();
return;
}

expect(memory).toMatchObject({
name: "my_agentMemory",
eventExpiryDuration: 30,
});
expect(memory.strategies.map(({ type }: { type: string }) => type)).toEqual(expectedStrategies);

const main = await Bun.file(join(projectRoot, "app", "my_agent", "main.py")).text();
const session = await Bun.file(
join(projectRoot, "app", "my_agent", "memory", "session.py"),
).text();
expect(main).toContain("from memory.session import get_memory_session_manager");
expect(session).toContain('MEMORY_ID = os.getenv("MEMORY_MY_AGENTMEMORY_ID")');
expect(session.includes("RetrievalConfig")).toBe(expectedStrategies.length > 0);
});

test.each<[string, string[]]>([
["missing --name", ["--template", "hello-world-python"]],
[
Expand Down Expand Up @@ -327,7 +379,7 @@ describe("project add runtime", () => {
],
[
"--template and --memory are mutually exclusive",
["--name", "my_agent", "--template", "hello-world-python", "--memory", "none"],
["--name", "my_agent", "--template", "strands-python", "--memory", "short"],
],
[
"strands-python only supports HTTP",
Expand All @@ -341,6 +393,7 @@ describe("project add runtime", () => {
"hello-world-python only supports HTTP",
["--name", "my_agent", "--template", "hello-world-python", "--protocol", "MCP"],
],
["runtime names are limited in length", ["--name", "x".repeat(43)]],
])("%s", async (_label, flags) => {
await inProject();
await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError);
Expand Down
Loading
Loading