Skip to content
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this retrieval namespace is different from the strategy being created. MEMORY_SHORTCUTS configures SUMMARIZATION as /summaries/{actorId}/{sessionId}, but the generated runtime queries /summaries/{actor_id}. That means summaries written under the configured session namespace will not be retrieved.

I think this should probablyt include session_id. I just looked and this mismatch also exists in the old template, but this PR makes that strategy part of the default memory so we may as well just make it right here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is actually intentional. The namespace in the MEMORY_SHORTCUTS is where the LTM records get written (session specific path), and then the agent retrieves those records across all sessions by dropping the sessionId on the retrieval path.

I see a PR from main that fixes this exact behavior: #1660.

@aidandaly24 aidandaly24 Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I didn't realize this. This is definitely correct I was treating the retrieval namespace like an exact match, thanks!

{{/if}}
}
{{/if}}

return AgentCoreMemorySessionManager(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now since we said we don't want to make to many changes, and I know you said you would eventually like to improve the template. One thing we should change is to use the new AgentCoreMemoryManager and AgentCoreMemoryStore at some point. We should theoretically be using our own best practices.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, good callout.

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,7 +6,7 @@ import { DeserializationError, ProjectStateError } from "../../errors/errors";
import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets";
import { ProjectSpecSchema } from "../../projectSchemas/project";
import { FsProjectManager } from "./manager";
import { RUNTIME_TEMPLATE_SHORTCUTS } from "../../handlers/project/shortcuts";
import { resolveRuntimeTemplateShortcut } from "../../handlers/project/shortcuts";
import {
type CreateProjectInput,
type DeployResult,
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
35 changes: 33 additions & 2 deletions 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 @@ -84,6 +88,29 @@ 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");
});

test("strips .template suffix from non-ignore files", async () => {
const source: AssetSource = {
async list() {
Expand All @@ -94,7 +121,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.children.map((node) => node.name)).toEqual(["Dockerfile", ".dockerignore"]);
});
Expand Down
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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now thats a code comment!

*
* @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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how we are using object types here.

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
37 changes: 26 additions & 11 deletions src/core/project/templates/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,17 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa
[buildResolverKey("none", "Python")]: async (input: RuntimeResourceConfig) => {
if (input.protocol !== undefined && input.protocol !== "HTTP")
throw new InputValidationError(`hello-world-python only supports HTTP protocol`);
if (input.scaffoldRuntimeInput.memory !== undefined)
throw new InputValidationError(`memory is not supported with the hello-world template`);
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)] } };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the custom hello-world path can silently ignore --memory. Selecting --framework none routes here, but even when scaffoldRuntimeInput.memory is set, this return only adds the Runtime and does not generate a memories[] entry in agentcore.json or the memory template files. Do you think we should reject --memory for this path for cleanliness in UX?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is indirectly fixed in #2130 (comment). Let me rebase and verify.

},
Expand All @@ -90,10 +95,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 @@ -108,14 +117,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
Loading
Loading