Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(templates): wire in memory to the runtime templates by Hweinstock · Pull Request #2116 · aws/agentcore-cli · GitHub
Skip to content
Empty file.
47 changes: 47 additions & 0 deletions src/assets/templates/strands-http-python/memory/session.py
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

@aidandaly24aidandaly24Aug 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
ContributorAuthor

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
ContributorAuthor

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