From 189e31551799edcf53a8e2b86d712140951fecda Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 13:32:23 -0400 Subject: [PATCH 1/9] feat(project): support runtime template profiles --- src/core/project/manager.test.ts | 105 +++++++++++++++++++ src/core/project/manager.tsx | 25 ++++- src/core/project/templates/runtime.ts | 83 +++++++++++++-- src/handlers/project/shortcuts.ts | 4 + src/handlers/project/templateProfile.test.ts | 49 +++++++++ src/handlers/project/templateProfile.ts | 48 +++++++++ src/handlers/project/types.ts | 3 + src/projectSchemas/runtime.ts | 2 +- 8 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 src/handlers/project/templateProfile.test.ts create mode 100644 src/handlers/project/templateProfile.ts diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index f0582359aa..6812a78af6 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -32,6 +32,7 @@ import { type DeployResult, type Project, type ProjectEvent, + type ScaffoldRuntimeInput, } from "../../handlers/project/types"; import { createSilentLogger, TestIdentityClient } from "../../testing"; import type { DeployBackendInput, ProjectBackend } from "./backends/types"; @@ -46,6 +47,13 @@ const AGENT_TYPESCRIPT_STRANDS = resolveRuntimeTemplateShortcut("agent-typescrip const A2A_PYTHON_STRANDS = resolveRuntimeTemplateShortcut("a2a-python-strands"); const AGENT_PYTHON_LANGCHAIN = resolveRuntimeTemplateShortcut("agent-python-langchain"); +function withTemplateProfile( + input: ScaffoldRuntimeInput, + profile: NonNullable, +): ScaffoldRuntimeInput { + return { ...input, templateProfile: profile }; +} + const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -384,6 +392,26 @@ describe("FsProjectManager.create", () => { expect(harness.checkedTools).toEqual(["npm", "git"]); }); + test("defers template-managed local dependency setup", async () => { + const directory = await inTempDirectory(); + const { manager: subject, commands, checkedTools } = manager(); + await runCreate(subject, { + name: "example", + scaffoldRuntimeInput: withTemplateProfile(AGENT_PYTHON, { + dependencySetup: "deferred", + }), + }); + + expect(commands).toEqual([ + { + command: ["npm", "install", "--loglevel=http"], + cwd: join(directory, "example", "agentcore", "cdk"), + }, + { command: ["git", "init"], cwd: join(directory, "example") }, + ]); + expect(checkedTools).toEqual(["npm", "git"]); + }); + test("skipInstall skips npm install and uv sync", async () => { const directory = await inTempDirectory(); const { manager: subject, commands, checkedTools } = manager(); @@ -423,6 +451,22 @@ describe("FsProjectManager.create", () => { }, ); + test("does not generate a container lockfile when dependency setup is deferred", async () => { + await inTempDirectory(); + const { manager: subject, commands, checkedTools } = manager(); + await runCreate(subject, { + name: "example", + scaffoldRuntimeInput: withTemplateProfile(AGENT_PYTHON_STRANDS_CONTAINER, { + dependencySetup: "deferred", + }), + skipInstall: true, + skipGit: true, + }); + + expect(commands).toEqual([]); + expect(checkedTools).toEqual([]); + }); + test("skipGit skips git init", async () => { await inTempDirectory(); const { manager: subject, commands, checkedTools } = manager(); @@ -492,6 +536,67 @@ describe("FsProjectManager.create", () => { }); describe("FsProjectManager.addResource", () => { + test("applies a runtime template profile and defers its dependency setup", async () => { + await inTempDirectory(); + const setup = manager(); + const { project } = await runCreate(setup.manager, { + name: "example", + scaffoldRuntimeInput: AGENT_PYTHON, + skipInstall: true, + skipGit: true, + }); + setup.commands.length = 0; + setup.checkedTools.length = 0; + + const runtimeName = "profiled_runtime"; + const updated = await runAdd(setup.manager, project, { + resourceType: "runtime", + resourceConfig: { + name: runtimeName, + additionalPolicies: ["custom-policy.json", "template-policy.json"], + lifecycleConfiguration: { maxLifetime: 600 }, + tags: { team: "runtime", "agentcore:template": "Override" }, + scaffoldRuntimeInput: { + ...withTemplateProfile(AGENT_PYTHON_STRANDS_CONTAINER, { + usesModel: false, + dependencySetup: "deferred", + runtime: { + entrypoint: "lifecycle/server.py", + dockerfile: "Dockerfile", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + additionalPolicies: ["template-policy.json"], + tags: { "agentcore:template": "Example" }, + }, + }), + runtimeName, + }, + }, + }); + + expect(updated.spec.runtimes).toContainEqual( + expect.objectContaining({ + name: runtimeName, + build: "Container", + entrypoint: "lifecycle/server.py", + dockerfile: "Dockerfile", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 600, + maxLifetime: 600, + }, + additionalPolicies: ["template-policy.json", "custom-policy.json"], + tags: { "agentcore:template": "Override", team: "runtime" }, + }), + ); + expect(updated.spec.runtimes.find(({ name }) => name === runtimeName)?.modelProvider).toBe( + undefined, + ); + expect(setup.commands).toEqual([]); + expect(setup.checkedTools).toEqual([]); + }); + test.each([ ["Python", AGENT_PYTHON, "uv"], ["TypeScript", AGENT_TYPESCRIPT_STRANDS, "npm"], diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index aaed77cea2..bca0a598e9 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -92,6 +92,7 @@ import type { TemplateRenderer } from "./templates/types"; import { HandlebarsTemplateRenderer } from "./templates/renderer"; import type { CreateCloudFormationClient } from "../types"; import type { CoreIdentityClient } from "../../handlers/identity/types"; +import { templateManagesDependencies } from "../../handlers/project/templateProfile"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; @@ -341,9 +342,12 @@ export class FsProjectManager implements ProjectManager { if (scaffoldRuntimeInput) { const appDir = join(destination, "app", scaffoldRuntimeInput.runtimeName); - yield* this.installRuntimeDependencies(appDir); + yield* this.installRuntimeDependencies(appDir, scaffoldRuntimeInput); } - } else if (scaffoldRuntimeInput?.build === "Container") { + } else if ( + scaffoldRuntimeInput?.build === "Container" && + templateManagesDependencies(scaffoldRuntimeInput.templateProfile) + ) { // Container builds install from a lockfile, so generate it even with no-install. const appDir = join(destination, "app", scaffoldRuntimeInput.runtimeName); yield* this.ensureLockFileExists(appDir); @@ -511,7 +515,10 @@ export class FsProjectManager implements ProjectManager { } } - yield* this.installRuntimeDependencies(outputPath); + yield* this.installRuntimeDependencies( + outputPath, + input.resourceConfig.scaffoldRuntimeInput, + ); break; } case "credential": { @@ -1368,7 +1375,10 @@ export class FsProjectManager implements ProjectManager { private async checkCreateDependencies(input: CreateProjectInput): Promise { if (!input.skipInstall) { await this.checkTool("npm", NODE_INSTALL_HINT); - if (input.scaffoldRuntimeInput?.language === "Python") { + if ( + input.scaffoldRuntimeInput?.language === "Python" && + templateManagesDependencies(input.scaffoldRuntimeInput.templateProfile) + ) { await this.checkTool("uv", UV_INSTALL_HINT); } } @@ -1380,6 +1390,7 @@ export class FsProjectManager implements ProjectManager { private async checkRuntimeDependency( input: RuntimeResourceConfig["scaffoldRuntimeInput"], ): Promise { + if (!templateManagesDependencies(input.templateProfile)) return; if (input.language === "Python") { await this.checkTool("uv", UV_INSTALL_HINT); } else { @@ -1391,7 +1402,11 @@ export class FsProjectManager implements ProjectManager { * Installs dependencies for a scaffolded runtime directory (e.g. `uv sync` * for Python). No-ops if the runtime has no recognized dependency manifest. */ - private async *installRuntimeDependencies(appDir: string): AsyncGenerator { + private async *installRuntimeDependencies( + appDir: string, + input?: RuntimeResourceConfig["scaffoldRuntimeInput"], + ): AsyncGenerator { + if (input && !templateManagesDependencies(input.templateProfile)) return; if (existsSync(join(appDir, "pyproject.toml"))) { await this.checkTool("uv", UV_INSTALL_HINT); yield { type: "step", message: "Syncing Python dependencies with uv" }; diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 8bdaf73812..f7b22eaeff 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -13,6 +13,7 @@ import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { defaultMemoryName, memoryEnvVarName } from "../../../projectSchemas/memory"; import { InputValidationError } from "../../../errors"; import { toPythonPackageName } from "../fsUtils"; +import { templateUsesModel } from "../../../handlers/project/templateProfile"; /** A model provider's render context, spec entries, and .env.local secrets for a scaffolded runtime. */ type ModelProviderTemplateConfig = { @@ -52,6 +53,19 @@ function resolveModelProviderScaffold(input: RuntimeResourceConfig): ModelProvid function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { const { scaffoldRuntimeInput, name, ...infra } = input; + const profile = scaffoldRuntimeInput.templateProfile; + const runtimeProfile = profile?.runtime; + const usesModel = templateUsesModel(profile); + const lifecycleConfiguration = mergeLifecycleConfiguration( + runtimeProfile?.lifecycleConfiguration, + infra.lifecycleConfiguration, + ); + const additionalPolicies = unique([ + ...(runtimeProfile?.additionalPolicies ?? []), + ...(infra.additionalPolicies ?? []), + ]); + const tags = + runtimeProfile?.tags || infra.tags ? { ...runtimeProfile?.tags, ...infra.tags } : undefined; return { name, build: scaffoldRuntimeInput.build, @@ -60,25 +74,31 @@ function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { // and Bedrock Agent imports (whose translated code calls Bedrock despite // framework "none"). Provider-free scaffolds (minimal, MCP) stay // unclassified. - ...((scaffoldRuntimeInput.framework !== "none" || input.importBedrockAgent !== undefined) && { - modelProvider: scaffoldRuntimeInput.modelProvider ?? "Bedrock", - }), + ...(usesModel && + (scaffoldRuntimeInput.framework !== "none" || input.importBedrockAgent !== undefined) && { + modelProvider: scaffoldRuntimeInput.modelProvider ?? "Bedrock", + }), // For LiteLLM the model id determines the actual routing (its 'bedrock/' // prefix routes to Amazon Bedrock), so persist the id the code renders — // explicit --model-id or the template default — for the China deploy gate. - ...(scaffoldRuntimeInput.modelProvider === "LiteLLM" && { - modelId: scaffoldRuntimeInput.modelId ?? DEFAULT_MODEL_IDS.LiteLLM, - }), + ...(usesModel && + scaffoldRuntimeInput.modelProvider === "LiteLLM" && { + modelId: scaffoldRuntimeInput.modelId ?? DEFAULT_MODEL_IDS.LiteLLM, + }), // TypeScript deploys a compiled main.js (esbuild runs at synth); Python runs main.py directly. - entrypoint: scaffoldRuntimeInput.language === "TypeScript" ? "main.js" : "main.py", + entrypoint: + runtimeProfile?.entrypoint ?? + (scaffoldRuntimeInput.language === "TypeScript" ? "main.js" : "main.py"), codeLocation: `app/${name}` as ProjectRuntime["codeLocation"], ...(scaffoldRuntimeInput.runtimeVersion && { runtimeVersion: scaffoldRuntimeInput.runtimeVersion, }), - ...(scaffoldRuntimeInput.build === "Container" && { dockerfile: "Dockerfile" }), + ...(scaffoldRuntimeInput.build === "Container" && { + dockerfile: runtimeProfile?.dockerfile ?? "Dockerfile", + }), ...(infra.description && { description: infra.description }), ...(infra.executionRoleArn && { executionRoleArn: infra.executionRoleArn }), - ...(infra.additionalPolicies && { additionalPolicies: infra.additionalPolicies }), + ...(additionalPolicies.length > 0 && { additionalPolicies }), ...(infra.envVars && { envVars: infra.envVars }), ...(infra.networkMode && { networkMode: infra.networkMode }), ...(infra.networkConfig && { networkConfig: infra.networkConfig }), @@ -88,11 +108,52 @@ function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { }), ...(infra.protocol && { protocol: infra.protocol }), ...(infra.requestHeaderAllowlist && { requestHeaderAllowlist: infra.requestHeaderAllowlist }), - ...(infra.lifecycleConfiguration && { lifecycleConfiguration: infra.lifecycleConfiguration }), + ...(lifecycleConfiguration && { lifecycleConfiguration }), ...(infra.filesystemConfigurations && { filesystemConfigurations: infra.filesystemConfigurations, }), - ...(infra.tags && { tags: infra.tags }), + ...(tags && { tags }), + }; +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} + +/** + * Merge lifecycle defaults without allowing an inherited value to invalidate an + * explicit value. If both explicit values conflict, ProjectRuntimeSchema still + * reports the error. + */ +function mergeLifecycleConfiguration( + defaults: ProjectRuntime["lifecycleConfiguration"], + overrides: ProjectRuntime["lifecycleConfiguration"], +): ProjectRuntime["lifecycleConfiguration"] { + if (!defaults) return overrides; + if (!overrides) return defaults; + + let idleRuntimeSessionTimeout = + overrides.idleRuntimeSessionTimeout ?? defaults.idleRuntimeSessionTimeout; + let maxLifetime = overrides.maxLifetime ?? defaults.maxLifetime; + + if ( + idleRuntimeSessionTimeout !== undefined && + maxLifetime !== undefined && + idleRuntimeSessionTimeout > maxLifetime + ) { + if (overrides.idleRuntimeSessionTimeout !== undefined && overrides.maxLifetime === undefined) { + maxLifetime = idleRuntimeSessionTimeout; + } else if ( + overrides.maxLifetime !== undefined && + overrides.idleRuntimeSessionTimeout === undefined + ) { + idleRuntimeSessionTimeout = maxLifetime; + } + } + + return { + ...(idleRuntimeSessionTimeout !== undefined && { idleRuntimeSessionTimeout }), + ...(maxLifetime !== undefined && { maxLifetime }), }; } diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts index cf73f539c8..58bc839f50 100644 --- a/src/handlers/project/shortcuts.ts +++ b/src/handlers/project/shortcuts.ts @@ -7,6 +7,7 @@ import { } from "../../projectSchemas/memory"; import { InputValidationError } from "../../errors"; import { ScaffoldRuntimeInputSchema, type ModelProvider, type ScaffoldRuntimeInput } from "./types"; +import type { RuntimeTemplateProfile } from "./templateProfile"; /** The default memory that templates ship with. */ export function getDefaultMemorySpec(runtimeName: string): Memory { @@ -39,6 +40,8 @@ type RuntimeTemplateShortcut = { /** Accepts --model-provider / --api-key overrides; Bedrock-only otherwise. */ supportsModelProviderOverride: boolean; runtimeVersion?: NonNullable; + /** Exceptional runtime and dependency behavior for this template. */ + profile?: RuntimeTemplateProfile; }; /** @@ -252,6 +255,7 @@ export function resolveRuntimeTemplateShortcut( ...(overrides?.apiKey !== undefined && { apiKey: overrides.apiKey }), ...(template.includesMemory && { memory: getDefaultMemorySpec(runtimeName) }), runtimeVersion: template.runtimeVersion, + templateProfile: template.profile, }; const result = ScaffoldRuntimeInputSchema.safeParse(input); diff --git a/src/handlers/project/templateProfile.test.ts b/src/handlers/project/templateProfile.test.ts new file mode 100644 index 0000000000..3c79874686 --- /dev/null +++ b/src/handlers/project/templateProfile.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { + RuntimeTemplateProfileSchema, + templateManagesDependencies, + templateUsesModel, +} from "./templateProfile"; + +describe("runtime template profiles", () => { + test("templates use models and managed dependencies by default", () => { + expect(templateUsesModel(undefined)).toBe(true); + expect(templateManagesDependencies(undefined)).toBe(true); + expect(templateUsesModel({})).toBe(true); + expect(templateManagesDependencies({})).toBe(true); + }); + + test("accepts template-specific runtime and dependency behavior", () => { + const profile = RuntimeTemplateProfileSchema.parse({ + usesModel: false, + dependencySetup: "deferred", + runtime: { + entrypoint: "lifecycle/server.py", + dockerfile: "Dockerfile", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + additionalPolicies: ["runtime-policy.json"], + tags: { "agentcore:template": "Example" }, + }, + }); + + expect(templateUsesModel(profile)).toBe(false); + expect(templateManagesDependencies(profile)).toBe(false); + }); + + test("rejects invalid runtime defaults", () => { + expect( + RuntimeTemplateProfileSchema.safeParse({ + runtime: { + dockerfile: "../Dockerfile", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 3600, + maxLifetime: 1800, + }, + }, + }).success, + ).toBe(false); + }); +}); diff --git a/src/handlers/project/templateProfile.ts b/src/handlers/project/templateProfile.ts new file mode 100644 index 0000000000..a34cd121af --- /dev/null +++ b/src/handlers/project/templateProfile.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { + DockerfilePathSchema, + EntrypointSchema, + LifecycleConfigurationSchema, +} from "../../projectSchemas/runtime"; +import { TagsSchema } from "../../projectSchemas/tags"; + +/** + * Template-specific behavior that cannot be expressed by the framework, + * language, protocol, and build fields alone. + * + * Profiles are internal scaffold metadata. They are consumed while creating the + * project tree and are not persisted in agentcore.json. + */ +export const RuntimeTemplateProfileSchema = z + .object({ + /** False when the Runtime hosts an environment but runs no model itself. */ + usesModel: z.boolean().optional(), + /** + * "managed" uses the template's dependency manifest to install local + * dependencies and generate container lockfiles. "deferred" leaves both to + * the generated template and its user. + */ + dependencySetup: z.enum(["managed", "deferred"]).optional(), + /** Runtime settings required by the template. Explicit Runtime input wins for scalar settings. */ + runtime: z + .object({ + entrypoint: EntrypointSchema.optional(), + dockerfile: DockerfilePathSchema.optional(), + lifecycleConfiguration: LifecycleConfigurationSchema.optional(), + additionalPolicies: z.array(z.string().min(1)).optional(), + tags: TagsSchema.optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export type RuntimeTemplateProfile = z.infer; + +export function templateUsesModel(profile: RuntimeTemplateProfile | undefined): boolean { + return profile?.usesModel !== false; +} + +export function templateManagesDependencies(profile: RuntimeTemplateProfile | undefined): boolean { + return profile?.dependencySetup !== "deferred"; +} diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 779c65a745..68c5f1f911 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -16,6 +16,7 @@ import type { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/poli import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; import type { ProgressEvent } from "../../tui/progress"; import type { AwsCredentialProvider } from "../../core/types"; +import { RuntimeTemplateProfileSchema } from "./templateProfile"; type CreateProjectInputBase = { /** The name of the project; also the directory it is scaffolded into. */ @@ -54,6 +55,8 @@ export const ScaffoldRuntimeInputSchema = z apiKey: z.string().min(1).optional(), memory: MemorySchema.optional(), runtimeVersion: RuntimeVersionSchema.optional(), + /** Internal metadata supplied by the selected template; never persisted in agentcore.json. */ + templateProfile: RuntimeTemplateProfileSchema.optional(), }) .superRefine(({ modelProvider, apiKey }, ctx) => { // LiteLLM routes to any provider (Bedrock by default), so its key is optional; diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index d7f08a9565..8bd4e6043a 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -57,7 +57,7 @@ export function isValidDockerfilePath(p: string): boolean { if (p.startsWith("/")) return false; return !p.split("/").some((segment) => segment === "" || segment === ".."); } -const DockerfilePathSchema = z +export const DockerfilePathSchema = z .string() .min(1) .max(255) From a6ba29639558c6af88291f89edf5cd0c4c81e52d Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 14:06:26 -0400 Subject: [PATCH 2/9] feat(project): add Bedrock managed agent template --- .../bedrock-managed-agents/Dockerfile | 41 ++ .../bedrock-managed-agents/README.md | 122 ++++ .../bma-acr-policy.json | 11 + .../bedrock-managed-agents/client.py | 132 ++++ .../lifecycle/server.py | 641 ++++++++++++++++++ .../otel/collector.yaml | 69 ++ .../acr-report/.codex-plugin/plugin.json | 6 + .../acr-report/skills/acr-report/SKILL.md | 16 + .../bedrock-managed-agents/pyproject.toml | 19 + src/core/project/manager.test.ts | 49 ++ src/core/project/templates/runtime.ts | 17 + .../project/add/runtime/index.test.ts | 84 ++- .../add/runtime/runtime.screen.test.tsx | 29 + src/handlers/project/bmaProfile.ts | 16 + .../project/create/create.screen.test.tsx | 2 + src/handlers/project/project.test.ts | 45 ++ src/handlers/project/shortcuts.test.ts | 26 + src/handlers/project/shortcuts.ts | 21 +- src/handlers/project/types.ts | 2 +- 19 files changed, 1340 insertions(+), 8 deletions(-) create mode 100644 src/assets/templates/bedrock-managed-agents/Dockerfile create mode 100644 src/assets/templates/bedrock-managed-agents/README.md create mode 100644 src/assets/templates/bedrock-managed-agents/bma-acr-policy.json create mode 100644 src/assets/templates/bedrock-managed-agents/client.py create mode 100644 src/assets/templates/bedrock-managed-agents/lifecycle/server.py create mode 100644 src/assets/templates/bedrock-managed-agents/otel/collector.yaml create mode 100644 src/assets/templates/bedrock-managed-agents/plugins/acr-report/.codex-plugin/plugin.json create mode 100644 src/assets/templates/bedrock-managed-agents/plugins/acr-report/skills/acr-report/SKILL.md create mode 100644 src/assets/templates/bedrock-managed-agents/pyproject.toml create mode 100644 src/handlers/project/bmaProfile.ts diff --git a/src/assets/templates/bedrock-managed-agents/Dockerfile b/src/assets/templates/bedrock-managed-agents/Dockerfile new file mode 100644 index 0000000000..249fd02775 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/Dockerfile @@ -0,0 +1,41 @@ +FROM public.ecr.aws/lambda/microvms:al2023-minimal + +# OS setup +RUN dnf install -y tar gzip findutils shadow-utils ca-certificates \ + && dnf clean all \ + && useradd -m -u 1000 app + +# Codex +RUN curl -fsSL https://chatgpt.com/codex/install.sh -o /tmp/install-codex.sh \ + && CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR=/opt/bma/bin CODEX_HOME=/opt/bma/codex \ + sh /tmp/install-codex.sh \ + && chmod -R a+rX /opt/bma \ + && rm /tmp/install-codex.sh + +# CloudWatch agent +RUN arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \ + && curl -fsSL -o /tmp/cwagent.rpm \ + https://amazoncloudwatch-agent.s3.amazonaws.com/amazon_linux/${arch}/latest/amazon-cloudwatch-agent.rpm \ + && rpm -i /tmp/cwagent.rpm \ + && rm /tmp/cwagent.rpm + +# uv and Python +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ +ARG UV_DEFAULT_INDEX +ARG UV_INDEX +ENV UV_PYTHON_INSTALL_DIR=/opt/python UV_PYTHON_BIN_DIR=/usr/local/bin +RUN uv python install --default + +# Application. The layout of /opt/bma is the same as the layout of this directory. +WORKDIR /opt/bma +ENV UV_COMPILE_BYTECODE=1 UV_NO_PROGRESS=1 \ + UV_DEFAULT_INDEX=${UV_DEFAULT_INDEX} UV_INDEX=${UV_INDEX} +COPY pyproject.toml uv.lock* ./ +RUN uv sync --no-dev +COPY lifecycle/ lifecycle/ +COPY otel/ otel/ +COPY plugins/ plugins/ +USER app + +EXPOSE 8080 +CMD ["uv", "run", "--no-sync", "opentelemetry-instrument", "python", "-u", "lifecycle/server.py"] diff --git a/src/assets/templates/bedrock-managed-agents/README.md b/src/assets/templates/bedrock-managed-agents/README.md new file mode 100644 index 0000000000..df09959dbe --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/README.md @@ -0,0 +1,122 @@ +This is a Bedrock Managed Agents (BMA) environment generated by the AgentCore CLI. + +# Layout + +BMA runs the agent loop (Codex) in the Bedrock Managed Agents service. This AgentCore Runtime (ACR) is the customer +environment where BMA runs commands. The ACR has no model code. + +| Path | Description | +| --- | --- | +| `Dockerfile` | The ACR image. It installs the Codex CLI with OpenAI's installer, the CloudWatch agent, Python with uv, and the dependencies in `pyproject.toml`. It copies `lifecycle/`, `otel/`, and `plugins/` to the same paths under `/opt/bma`, the working directory of the image. | +| `lifecycle/server.py` | The environment lifecycle server, `bma-acr-lifecycle`. It handles the lifecycle calls from BMA and starts `codex exec-server`. | +| `otel/collector.yaml` | The configuration of the CloudWatch agent. The agent gets the spans and logs of `codex exec-server`, puts the session ID on them, and sends them to X-Ray and CloudWatch Logs with the ACR role. To turn off observability, add `DISABLE_ADOT_OBSERVABILITY` with the value `true` to `envVars`. | +| `plugins/acr-report` | A Codex plugin with the `acr-report` skill. The skill saves the Python version, the user ID, and the working directory in `acr-report.txt`. | +| `bma-acr-policy.json` | Lets the ACR role call `bedrock-mantle:RegisterEnvironment` and `bedrock-mantle:ConnectEnvironment` on every Mantle project. To limit the role to your projects, change `Resource` to `arn:aws:bedrock-mantle:::project/`. | +| `pyproject.toml` | The Python dependencies. `aws-opentelemetry-distro` sends a span for each call from BMA and a child span for each step of the call, for example the state load or the exec-server start. `bedrock-agentcore` is the AgentCore SDK. The `dev` group has the dependencies of `client.py`, and the image does not install it. Each image build installs the latest Python and the latest releases. To pin them, run `uv lock` and keep `uv.lock` next to this file. | +| `client.py` | A sample OpenAI SDK client. It creates a session in BMA, and BMA sends the session's commands to this ACR. | + +Do not change `lifecycle/server.py`. It must match the lifecycle calls that BMA makes. + +The server always sends HTTP 200, because the Runtime changes any other status to 424 and drops the body. If a call +fails, the body has the HTTP status code of the failure in `status_code` and the reason in `error`, for example +`{"error": "the exec-server did not start", "status_code": 503}`. + +# Codex version + +The image build installs the latest Codex release. The server needs Codex 0.154.0 or newer because it runs +`codex exec-server`. To pin a release, set `CODEX_RELEASE` next to `CODEX_NON_INTERACTIVE` in the `Dockerfile`. The +image build downloads Codex and Python from the internet, so a build in VPC mode needs a route to the internet. + +# ACR settings + +`agentcore create` writes these settings to `agentcore/agentcore.json`: + +- An idle timeout of 1800 seconds (30 minutes) and a maximum lifetime of 28800 + seconds (8 hours). +- No session storage. Session storage is only for a microVM Runtime, so without it the same settings work on a + capacity provider. + +A value that you give to `agentcore create` replaces the value above. + +The server keeps the connection state in `state.json` in `BMA_STATE_DIR`. The default is `/home/app/.bma`. The client +sets the workspace in `workspace_directory` when it creates the session. `client.py` uses `/home/app/workspace`. The +server creates that directory and runs the agent commands in it. If the activate call has no workspace directory, the +server returns status 400. If the server cannot create the directory, it returns status 503, logs +`workspace_unavailable`, and does not start the exec-server. + +Files in the home directory do not stay after an idle stop. On a microVM Runtime, to keep the files and the connection +state after an idle stop, put the home directory on session storage: + +1. Add session storage with `--session-storage-mount-path /mnt/home`. +2. Set `BMA_HOME_DIR` to `/mnt/home` in `envVars`. Then `state.json` is in `/mnt/home/.bma`, and `CODEX_HOME` is + `/mnt/home/.codex`. +3. Set `WORKSPACE_DIRECTORY` in `client.py` to `/mnt/home/workspace`. + +If you set `WORKSPACE_DIRECTORY` to a path on session storage, the Runtime must have session storage. If not, the +server cannot create the workspace, and the session fails. If `BMA_STATE_DIR` is not on a mounted path, or the server +cannot create it, the server keeps `state.json` in `.bma` in `BMA_HOME_DIR`. + +# Environment variables + +To change a setting of the server, add the variable to `envVars` of the agent in `agentcore/agentcore.json`. Then run +`agentcore deploy`. For example: + +```json +"envVars": [ + { "name": "BMA_MAX_TURN_LEASE", "value": "600" }, + { "name": "BMA_CODEX_BINARY", "value": "/opt/bma/bin/codex" } +] +``` + +| Variable | Default | Description | +| --- | --- | --- | +| `DISABLE_ADOT_OBSERVABILITY` | Not set | Set to `true` to turn off the traces and the exec-server logs. The server still writes its own logs. | +| `BMA_STATE_DIR` | `.bma` in `BMA_HOME_DIR` | The directory of `state.json`. | +| `BMA_HOME_DIR` | The home directory of the image user | `HOME` of the exec-server. | +| `BMA_CODEX_HOME` | `.codex` in `BMA_HOME_DIR` | `CODEX_HOME` of the exec-server. | +| `BMA_CODEX_BINARY` | `/opt/bma/bin/codex` | The Codex binary. | +| `BMA_MAX_TURN_LEASE` | `300` | The longest turn lease, in seconds. The server changes a longer request to this value. | + + +# Skills and plugins + +BMA finds skills and plugins in the directories that the client gives in `capability_directories`. A directory can be +at any absolute path in the ACR. BMA does not need a copy in the workspace. + +- To add a skill to the image, put a plugin under `plugins/` and add its path under `/opt/bma/plugins` to + `CAPABILITY_DIRECTORIES` in `client.py`. A plugin has a `.codex-plugin/plugin.json` file and a `skills/` directory + with one directory for each skill. Each skill directory has a `SKILL.md` file. +- To share skills from an S3 bucket, mount an S3 Files access point, for example at `/mnt/skills`, and add that + path to `CAPABILITY_DIRECTORIES`. Add the mount to `filesystemConfigurations` in `agentcore/agentcore.json` as an + `s3FilesAccessPoint` entry. An S3 Files mount needs VPC network mode. + +# Deploy + +```bash +agentcore deploy +``` + +Give the ACR ARN to BMA when you create the BMA environment. + +# Run the client + +Run the client from this directory with the ACR ARN from `agentcore status`: + +```bash +uv run client.py --runtime +``` + +`uv run` installs the dependencies and the `dev` group from `pyproject.toml` in `.venv`. The client creates a BMA +session and prints the session ID, each command with its output, and the answer of the agent as it streams. + +To send another input to the same session, add the BMA session ID. If the session does not exist, the client creates a +new session and prints its ID. + +```bash +uv run client.py --runtime --session-id --input "List the files in the workspace." +``` + +If you add a Gateway, add `--gateway ` with the URL from the output of `agentcore deploy`. BMA adds +the Gateway tools when it creates the session, so the flag has no effect on a session that exists. + +To delete the session after the turn, add `--delete`. To print each stream event as JSON, add `--raw`. diff --git a/src/assets/templates/bedrock-managed-agents/bma-acr-policy.json b/src/assets/templates/bedrock-managed-agents/bma-acr-policy.json new file mode 100644 index 0000000000..ffe5ebed1f --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/bma-acr-policy.json @@ -0,0 +1,11 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AttachToBmaEnvironment", + "Effect": "Allow", + "Action": ["bedrock-mantle:RegisterEnvironment", "bedrock-mantle:ConnectEnvironment"], + "Resource": "arn:*:bedrock-mantle:*:*:project/*" + } + ] +} diff --git a/src/assets/templates/bedrock-managed-agents/client.py b/src/assets/templates/bedrock-managed-agents/client.py new file mode 100644 index 0000000000..a23f36856a --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/client.py @@ -0,0 +1,132 @@ +"""Send an input to a Bedrock Managed Agents session that uses this project's ACR.""" + +import argparse +import json +from typing import Any + +from aws_bedrock_token_generator import provide_token +from openai import NotFoundError, OpenAI + +BMA_MODEL_ID = "openai.gpt-5.6-luna" +WORKSPACE_DIRECTORY = "/home/app/workspace" +CAPABILITY_DIRECTORIES = ["/opt/bma/plugins"] +TURN_END = ("completed", "failed", "cancelled") +TOOL_CALLS = ("mcp_call", "function_call", "web_search_call") + + +def show(data: dict[str, Any]) -> None: + """Prints the session ID, the commands, the tool calls, and the answer.""" + kind = data["type"].removeprefix("agent.session.") + item = data.get("item") or {} + if kind == "created": + print(f"Session {data['session']['id']}") + elif kind == "turn.output_text.delta": + print(data["delta"], end="", flush=True) + elif kind == "turn.item.done" and item.get("type") == "command_execution": + print(f"\n$ {item['command']}\n{item.get('output') or ''}".rstrip()) + elif kind == "turn.item.done" and item.get("type") in TOOL_CALLS: + print(f"\nTool {item.get('name') or item['type']} {item.get('status')}") + elif kind == "error" or kind.split(".")[-1] in TURN_END: + source = data.get("turn") or data.get("environment") or data.get("session") + print(f"\n{kind} {(source or data).get('error') or ''}".rstrip()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", required=True, help="The ACR ARN.") + parser.add_argument( + "--session-id", + help="The BMA session ID. If it does not exist, the client creates a session.", + ) + parser.add_argument( + "--input", + default="Use the acr-report skill to save an ACR report in the workspace.", + ) + parser.add_argument( + "--gateway", + help="The Gateway URL from the output of `agentcore deploy`.", + ) + parser.add_argument("--delete", action="store_true", help="Delete the session.") + parser.add_argument("--raw", action="store_true", help="Print events as JSON.") + args = parser.parse_args() + # BMA must run in the Region of the ACR. + region = args.runtime.split(":")[3] + + with OpenAI( + api_key=lambda: provide_token(region=region), + base_url=f"https://bedrock-mantle.{region}.api.aws/openai/v1", + ) as client: + sessions = client.beta.agents.sessions + session_id = args.session_id + if session_id: + try: + session = sessions.retrieve(session_id).model_dump(warnings=False) + except NotFoundError: + print(f"Session {session_id} does not exist.") + session_id = None + else: + if session["environment"].get("runtime_arn") != args.runtime: + raise ValueError(f"Session {session_id} uses another ACR.") + + if session_id: + # BMA opens the stream only with stream=true, and the SDK does not send it. + events = sessions.events.stream(session_id, extra_query={"stream": "true"}) + message = { + "role": "user", + "content": [{"type": "input_text", "text": args.input}], + } + sessions.events.create( + session_id, + events=[{"type": "agent.session.input.message", "input": [message]}], + ) + else: + agent: dict[str, Any] = { + "model": BMA_MODEL_ID, + "instructions": "Use the available tools to complete the task.", + } + if args.gateway: + # Bedrock Managed Agents calls Gateway with IAM from the service side. + agent["tools"] = [ + { + "type": "mcp", + "server_label": "team_tools", + "required": True, + "connection_origin": "service", + "transport": {"type": "http", "server_url": args.gateway}, + } + ] + args.input += ( + " Then use the Gateway's documentation and runbook tools to explain" + " how to investigate an MCP connection failure. Cite your sources." + ) + events = sessions.create( + agent=agent, + environment={ + "type": "aws_bedrock_agentcore", + "runtime_arn": args.runtime, + "runtime_qualifier": "DEFAULT", + "workspace_directory": WORKSPACE_DIRECTORY, + "capability_directories": CAPABILITY_DIRECTORIES, + }, + input=args.input, + stream=True, + ) + + with events: + for event in events: + data = event.model_dump(mode="json", warnings=False) + session_id = session_id or (data.get("session") or {}).get("id") + if args.raw: + print(json.dumps(data), flush=True) + else: + show(data) + if data["type"].removeprefix("agent.session.turn.") in TURN_END: + break + + if args.delete: + sessions.delete(session_id) + print(f"\nDeleted session {session_id}") + + +if __name__ == "__main__": + main() diff --git a/src/assets/templates/bedrock-managed-agents/lifecycle/server.py b/src/assets/templates/bedrock-managed-agents/lifecycle/server.py new file mode 100644 index 0000000000..1d54e46080 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/lifecycle/server.py @@ -0,0 +1,641 @@ +"""AgentCore Runtime (ACR) lifecycle server for a BMA exec-server. + +AgentCore calls ``GET /ping`` for health and ``POST /invocations`` for the BMA +lifecycle calls ``activate``, ``renew_turn_lease``, ``release_turn_lease``, and +``disconnect``. An invocation with no action returns the status. + +The server runs one ``codex exec-server`` for the attachment of the last +``activate`` call and saves the attachment in ``state.json`` in ``BMA_STATE_DIR``. +The default is ``.bma`` in the home directory. If the server cannot create +``BMA_STATE_DIR``, it keeps ``state.json`` in ``.bma`` in the home directory. +AgentCore mounts session storage only when an invocation starts, so the server +reads ``state.json`` on the first invocation. On replacement compute, that +invocation starts the exec-server again. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import shlex +import signal +import subprocess +import threading +import time +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from opentelemetry import propagate, trace + +BMA_NAME = "bma-acr-lifecycle" +BMA_VERSION = "0.1.0" +TRACER = trace.get_tracer(BMA_NAME, BMA_VERSION) + +BMA_CODEX_BINARY = Path(os.environ.get("BMA_CODEX_BINARY", "/opt/bma/bin/codex")) +BMA_HOME_DIR = Path(os.environ.get("BMA_HOME_DIR", str(Path.home()))) +BMA_STATE_DIR = Path(os.environ.get("BMA_STATE_DIR", str(BMA_HOME_DIR / ".bma"))) +BMA_CODEX_HOME = Path(os.environ.get("BMA_CODEX_HOME", str(BMA_HOME_DIR / ".codex"))) +BMA_MAX_TURN_LEASE = max(1, int(os.environ.get("BMA_MAX_TURN_LEASE", "300"))) + +AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") +BMA_REMOTE_SERVICE = "bedrock-mantle" + +COLLECTOR_BINARY = Path("/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent") +COLLECTOR_CONFIG = Path("/opt/bma/otel/collector.yaml") +COLLECTOR_ENDPOINT = "http://127.0.0.1:4318" +COLLECTOR_COMMAND = [ + str(COLLECTOR_BINARY), + "-config", + os.devnull, + "-otelconfig", + str(COLLECTOR_CONFIG), +] +OBSERVABILITY_ENABLED = ( + os.environ.get("AGENT_OBSERVABILITY_ENABLED", "").lower() == "true" +) +EXPORTER = '{otlp-http={endpoint="%s/v1/%s",protocol="binary"}}' +TELEMETRY_OVERRIDES = [ + "-c", + "otel.trace_exporter=" + EXPORTER % (COLLECTOR_ENDPOINT, "traces"), + "-c", + "otel.exporter=" + EXPORTER % (COLLECTOR_ENDPOINT, "logs"), +] + +EXEC_SERVER_ENVIRONMENT = { + "RUST_LOG": "codex_exec_server=info,codex_cli=info", + "RUST_BACKTRACE": "1", + "HOME": str(BMA_HOME_DIR), + "CODEX_HOME": str(BMA_CODEX_HOME), + "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "false", + "OTEL_PYTHON_LOG_CORRELATION": "false", +} + +PROTOCOL_VERSION = 1 +RESTART_DELAY_SECONDS = 5 +STOP_GRACE_SECONDS = 3 + + +def usable_dir(preferred: Path, fallback: Path) -> Path: + """Creates and returns preferred, or fallback if the server cannot create preferred.""" + try: + preferred.mkdir(parents=True, exist_ok=True) + return preferred + except OSError as error: + log("directory_fallback", directory=str(preferred), error=str(error)) + fallback.mkdir(parents=True, exist_ok=True) + return fallback + + +def state_dir() -> Path: + home = BMA_HOME_DIR / ".bma" + return usable_dir(BMA_STATE_DIR if BMA_STATE_DIR.parent.is_dir() else home, home) + + +def now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def log(message: str, **details: Any) -> None: + record = {"timestamp": now(), "runtimeSessionId": LIFECYCLE.runtime_session_id} + print(json.dumps({**record, "message": message, **details}), flush=True) + + +def step_span(name: str, **attributes: Any) -> Any: + """Starts a child span of the current span. The span has the session ID for the console. + + A step outside a BMA call, for example an exec-server restart by the monitor thread, gets no span, so it does not + start a new trace. + """ + if not trace.get_current_span().get_span_context().is_valid: + return contextlib.nullcontext(trace.INVALID_SPAN) + if LIFECYCLE.runtime_session_id: + attributes["session.id"] = LIFECYCLE.runtime_session_id + return TRACER.start_as_current_span(name, attributes=attributes) + + +def failed( + span: trace.Span, message: str, error: Exception | str, **details: Any +) -> None: + """Logs a failed step and sets the error on its span.""" + log(message, **details, error=str(error)) + if isinstance(error, Exception): + span.record_exception(error) + span.set_status(trace.StatusCode.ERROR, str(error)) + + +def valid_generation(generation: Any) -> bool: + return type(generation) is int and generation >= 0 + + +def parse_attachment(request: dict[str, Any]) -> tuple[str, int] | None: + """Returns the environment ID and the attachment generation of a BMA call.""" + version = request.get("protocol_version") + environment_id = request.get("environment_id") + generation = request.get("attachment_generation") + if type(version) is not int or version != PROTOCOL_VERSION: + return None + if not isinstance(environment_id, str) or not environment_id: + return None + if not valid_generation(generation): + return None + return environment_id, generation + + +BAD_ATTACHMENT = { + "error": ( + f"protocol_version must be {PROTOCOL_VERSION}, environment_id is required, and " + "attachment_generation must be a non-negative integer" + ) +} +EXEC_SERVER_DOWN = {"error": "the exec-server did not start"} +ATTACHMENT_TEXT_FIELDS = ( + "environment_id", + "workspace_directory", + "endpoint", + "region", + "service", +) + + +def valid_attachment(attachment: Any) -> bool: + """Returns whether an attachment from activate or state.json can start the exec-server.""" + if not isinstance(attachment, dict): + return False + if not valid_generation(attachment.get("attachment_generation")): + return False + return all( + isinstance(attachment.get(field), str) + and attachment[field] + and "\0" not in attachment[field] + for field in ATTACHMENT_TEXT_FIELDS + ) + + +class Lifecycle: + """Keeps the attachment, the exec-server, the collector, and the turn leases.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.runtime_session_id: str | None = None + self.state_loaded = False + self.attachment: dict[str, Any] | None = None + self.process: subprocess.Popen[str] | None = None + self.collector: subprocess.Popen[bytes] | None = None + self.leases: dict[str, float] = {} + + def load_state(self, runtime_session_id: str | None) -> None: + """Starts the exec-server for the saved attachment on the first invocation.""" + with self.lock: + if runtime_session_id: + self.runtime_session_id = runtime_session_id + if self.state_loaded: + return + self.state_loaded = True + with step_span("bma.state_load") as span: + try: + saved = json.loads( + (state_dir() / "state.json").read_text(encoding="utf-8") + ) + except FileNotFoundError: + span.set_attribute("bma.state.found", False) + return + except (OSError, ValueError) as error: + failed(span, "state_load_failed", error) + return + span.set_attribute("bma.state.found", True) + attachment = ( + saved.get("attachment") if isinstance(saved, dict) else None + ) + if valid_attachment(attachment): + self.attachment = attachment + log("state_loaded", **attachment) + self._ensure_running_locked("state_loaded") + elif attachment is not None: + failed( + span, "state_load_failed", "state.json has a wrong attachment" + ) + + def _save_locked(self) -> None: + with step_span("bma.state_save") as span: + try: + directory = state_dir() + temporary = directory / "state.json.tmp" + temporary.write_text( + json.dumps({"attachment": self.attachment}), encoding="utf-8" + ) + temporary.replace(directory / "state.json") + except OSError as error: + failed(span, "state_save_failed", error) + + def activate(self, request: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]: + parsed = parse_attachment(request) + if parsed is None: + return HTTPStatus.BAD_REQUEST, BAD_ATTACHMENT + workspace = request.get("workspace") + directory = workspace.get("directory") if isinstance(workspace, dict) else None + if not isinstance(directory, str) or not Path(directory).is_absolute(): + return HTTPStatus.BAD_REQUEST, { + "error": "workspace.directory must be an absolute path" + } + with step_span( + "bma.workspace_check", **{"bma.workspace.directory": directory} + ) as span: + try: + Path(directory).mkdir(parents=True, exist_ok=True) + except OSError as error: + failed(span, "workspace_unavailable", error, directory=directory) + return HTTPStatus.SERVICE_UNAVAILABLE, { + "error": f"cannot create workspace.directory: {error}" + } + environment_id, generation = parsed + region = request.get("region") or AWS_REGION + attachment = { + "environment_id": environment_id, + "attachment_generation": generation, + "workspace_directory": directory, + "endpoint": request.get("endpoint") + or f"https://{BMA_REMOTE_SERVICE}.{region}.api.aws", + "region": region, + "service": request.get("service") or BMA_REMOTE_SERVICE, + } + if not valid_attachment(attachment): + return HTTPStatus.BAD_REQUEST, { + "error": "workspace.directory, endpoint, region, and service must be strings" + } + + with self.lock: + current_id, current_generation = self._key() + if current_id == environment_id and generation < current_generation: + return self._conflict("stale attachment generation") + same = (current_id, current_generation) == parsed + if not (same and self._running()): + self._stop_locked("activate") + if not same: + self.leases.clear() + self.attachment = attachment + self._save_locked() + if not self._ensure_running_locked("activate"): + return HTTPStatus.SERVICE_UNAVAILABLE, EXEC_SERVER_DOWN + + return HTTPStatus.OK, { + "protocol_version": PROTOCOL_VERSION, + "environment_id": environment_id, + "attachment_generation": generation, + "workspace": "ready", + "exec_server": "connecting", + "runtime_session_id": self.runtime_session_id, + } + + def disconnect(self) -> tuple[HTTPStatus, dict[str, Any]]: + with self.lock: + self._stop_locked("disconnect") + self.attachment = None + self.leases.clear() + self._save_locked() + return HTTPStatus.OK, { + "protocol_version": PROTOCOL_VERSION, + "exec_server": "stopped", + } + + def renew_turn_lease( + self, request: dict[str, Any] + ) -> tuple[HTTPStatus, dict[str, Any]]: + turn_id = request.get("turn_id") + duration = request.get("lease_duration_seconds") + if ( + not isinstance(turn_id, str) + or not turn_id + or type(duration) is not int + or duration < 1 + ): + return HTTPStatus.BAD_REQUEST, { + "error": "turn_id and a positive integer lease_duration_seconds are required" + } + accepted = min(duration, BMA_MAX_TURN_LEASE) + with self.lock: + if error := self._check_lease_locked(request): + return error + if not self._ensure_running_locked("renew_turn_lease"): + return HTTPStatus.SERVICE_UNAVAILABLE, EXEC_SERVER_DOWN + deadline = time.monotonic() + accepted + self.leases[turn_id] = max(self.leases.get(turn_id, deadline), deadline) + body = self._lease_body(turn_id, lease_duration_seconds=accepted) + log( + "turn_lease_renewed", + turnId=turn_id, + leaseDurationSeconds=accepted, + activeLeases=body["active_lease_count"], + ) + return HTTPStatus.OK, body + + def release_turn_lease( + self, request: dict[str, Any] + ) -> tuple[HTTPStatus, dict[str, Any]]: + turn_id = request.get("turn_id") + if not isinstance(turn_id, str) or not turn_id: + return HTTPStatus.BAD_REQUEST, {"error": "turn_id is required"} + with self.lock: + if error := self._check_lease_locked(request): + return error + released = self.leases.pop(turn_id, None) is not None + body = self._lease_body(turn_id, released=released) + log( + "turn_lease_released", + turnId=turn_id, + released=released, + activeLeases=body["active_lease_count"], + ) + return HTTPStatus.OK, body + + def _lease_body(self, turn_id: str, **fields: Any) -> dict[str, Any]: + environment_id, generation = self._key() + return { + "protocol_version": PROTOCOL_VERSION, + "environment_id": environment_id, + "attachment_generation": generation, + "turn_id": turn_id, + **fields, + "active_lease_count": len(self.leases), + } + + def _check_lease_locked( + self, request: dict[str, Any] + ) -> tuple[HTTPStatus, dict[str, Any]] | None: + parsed = parse_attachment(request) + if parsed is None: + return HTTPStatus.BAD_REQUEST, BAD_ATTACHMENT + with step_span("bma.attachment_check") as span: + matched = self._key() == parsed + span.set_attribute("bma.attachment.matched", matched) + if not matched: + return self._conflict("turn lease does not match the active attachment") + self._prune_locked() + return None + + def _conflict(self, message: str) -> tuple[HTTPStatus, dict[str, Any]]: + environment_id, generation = self._key() + return HTTPStatus.CONFLICT, { + "error": message, + "environment_id": environment_id, + "attachment_generation": generation, + } + + def _key(self) -> tuple[str | None, int | None]: + """Returns the environment ID and the generation of the attachment.""" + attachment = self.attachment or {} + return attachment.get("environment_id"), attachment.get("attachment_generation") + + def _prune_locked(self) -> None: + cutoff = time.monotonic() + self.leases = {turn: end for turn, end in self.leases.items() if end > cutoff} + + def health(self) -> str: + """Does not take the lock, so an exec-server stop does not delay /ping.""" + cutoff = time.monotonic() + busy = any(end > cutoff for end in self.leases.copy().values()) + return "HealthyBusy" if busy else "Healthy" + + def status(self) -> dict[str, Any]: + with self.lock: + self._prune_locked() + return { + "service": f"{BMA_NAME}/{BMA_VERSION}", + "runtimeSessionId": self.runtime_session_id, + "attachment": self.attachment, + "execServerRunning": self._running(), + "activeLeaseCount": len(self.leases), + } + + def _running(self) -> bool: + return self.process is not None and self.process.poll() is None + + def _ensure_running_locked(self, reason: str) -> bool: + """Starts the exec-server for the attachment if it does not run. Returns whether it runs.""" + attachment = self.attachment + if attachment is None: + return False + if self._running(): + return True + with step_span("bma.exec_server_start", **{"bma.reason": reason}) as span: + command = [ + str(BMA_CODEX_BINARY), + *(TELEMETRY_OVERRIDES if self._ensure_collector_locked() else []), + "exec-server", + "--remote", + f"{attachment['endpoint'].rstrip('/')}/v1", + "--environment-id", + attachment["environment_id"], + "--remote-transport", + "direct", + "--aws-sigv4", + "--aws-service", + attachment["service"], + "--aws-region", + attachment["region"], + ] + try: + workspace = Path(attachment["workspace_directory"]) + workspace.mkdir(parents=True, exist_ok=True) + BMA_HOME_DIR.mkdir(parents=True, exist_ok=True) + BMA_CODEX_HOME.mkdir(parents=True, exist_ok=True) + log( + "exec_server_start", + reason=reason, + command=shlex.join(command), + cwd=str(workspace), + **attachment, + ) + self.process = subprocess.Popen( + command, + cwd=workspace, + env={**os.environ, **EXEC_SERVER_ENVIRONMENT}, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + ) + except (OSError, ValueError) as error: + self.process = None + failed(span, "exec_server_start_failed", error) + return False + span.set_attribute("process.pid", self.process.pid) + threading.Thread(target=self._drain, args=(self.process,), daemon=True).start() + return True + + def _ensure_collector_locked(self) -> bool: + """Starts the collector if it does not run. Returns whether it gets the Codex spans and logs.""" + if not OBSERVABILITY_ENABLED or not self.runtime_session_id: + return False + if self.collector is not None and self.collector.poll() is None: + return True + environment = { + **os.environ, + "AGENTCORE_RUNTIME_SID": self.runtime_session_id, + } + with step_span("bma.collector_start") as span: + try: + self.collector = subprocess.Popen(COLLECTOR_COMMAND, env=environment) + except OSError as error: + failed(span, "collector_start_failed", error) + return False + span.set_attribute("process.pid", self.collector.pid) + log("collector_start", pid=self.collector.pid) + return True + + def _stop_locked(self, reason: str) -> None: + process, self.process = self.process, None + if process is None or process.poll() is not None: + return + log("exec_server_stop", reason=reason, pid=process.pid) + with step_span( + "bma.exec_server_stop", **{"bma.reason": reason, "process.pid": process.pid} + ) as span: + process.terminate() + try: + process.wait(timeout=STOP_GRACE_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + span.set_attribute("bma.killed", True) + + @staticmethod + def _drain(process: subprocess.Popen[str]) -> None: + for line in process.stdout or []: + log("exec_server_output", line=line.rstrip("\n")) + log("exec_server_exited", returnCode=process.wait()) + + def monitor(self) -> None: + """Starts the exec-server again if it stops while an attachment is active.""" + while True: + time.sleep(RESTART_DELAY_SECONDS) + with self.lock: + self._ensure_running_locked("monitor") + + def shutdown(self) -> None: + with self.lock: + self._stop_locked("shutdown") + if self.collector is not None: + self.collector.terminate() + + def dispatch( + self, action: str, request: dict[str, Any] + ) -> tuple[HTTPStatus, dict[str, Any]]: + handlers = { + "activate": self.activate, + "renew_turn_lease": self.renew_turn_lease, + "release_turn_lease": self.release_turn_lease, + "disconnect": lambda _: self.disconnect(), + "status": lambda _: (HTTPStatus.OK, self.status()), + } + if action not in handlers: + return HTTPStatus.BAD_REQUEST, {"error": f"unknown action: {action}"} + return handlers[action](request) + + +LIFECYCLE = Lifecycle() + + +class Handler(BaseHTTPRequestHandler): + server_version = f"{BMA_NAME}/{BMA_VERSION}" + protocol_version = "HTTP/1.1" + + def send_json(self, status: HTTPStatus, body: dict[str, Any]) -> None: + """Sends HTTP 200, because the Runtime drops the body of any other status.""" + if status != HTTPStatus.OK: + body = {**body, "status_code": int(status)} + encoded = json.dumps(body).encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def read_body(self) -> bytes: + """Reads the request body, with Content-Length or chunked transfer encoding.""" + if "chunked" not in self.headers.get("Transfer-Encoding", "").lower(): + length = int(self.headers.get("Content-Length") or 0) + return self.rfile.read(length) if length > 0 else b"" + chunks = [] + while size := int(self.rfile.readline().split(b";")[0], 16): + chunks.append(self.rfile.read(size)) + self.rfile.readline() + # Read the trailer section to the empty line that ends the request. + while self.rfile.readline().strip(): + pass + return b"".join(chunks) + + def do_GET(self) -> None: + if self.path == "/ping": + self.send_json(HTTPStatus.OK, {"status": LIFECYCLE.health()}) + else: + self.send_json(HTTPStatus.NOT_FOUND, {"error": "route not found"}) + + def do_POST(self) -> None: + if self.path != "/invocations": + self.close_connection = True + self.send_json(HTTPStatus.NOT_FOUND, {"error": "route not found"}) + return + try: + body = self.read_body() + request = json.loads(body) if body else {} + except ValueError: + request = None + if not isinstance(request, dict): + self.close_connection = True + self.send_json( + HTTPStatus.BAD_REQUEST, {"error": "request body must be a JSON object"} + ) + return + runtime_session_id = self.headers.get( + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id" + ) + action = str(request.get("action", "status")) + with TRACER.start_as_current_span( + "bma.invocation", + context=propagate.extract(self.headers), + kind=trace.SpanKind.SERVER, + attributes={"bma.action": action}, + ) as span: + if runtime_session_id: + span.set_attribute("session.id", runtime_session_id) + try: + LIFECYCLE.load_state(runtime_session_id) + status, body = LIFECYCLE.dispatch(action, request) + except Exception as error: + log("dispatch_failed", action=action, error=str(error)) + span.record_exception(error) + status, body = ( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"error": "internal error"}, + ) + span.set_attribute("http.response.status_code", int(status)) + if status >= HTTPStatus.INTERNAL_SERVER_ERROR: + span.set_status(trace.StatusCode.ERROR) + self.send_json(status, body) + + def log_message(self, message_format: str, *args: Any) -> None: + if getattr(self, "path", None) == "/ping": + return + log("http", client=self.client_address[0], request=message_format % args) + + +def serve() -> None: + def handle(signal_number: int, _frame: Any) -> None: + log("signal", signal=signal_number) + LIFECYCLE.shutdown() + raise SystemExit(0) + + signal.signal(signal.SIGTERM, handle) + signal.signal(signal.SIGINT, handle) + threading.Thread(target=LIFECYCLE.monitor, daemon=True).start() + log("server_ready", port=8080, stateDir=str(BMA_STATE_DIR)) + ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() + + +if __name__ == "__main__": + serve() diff --git a/src/assets/templates/bedrock-managed-agents/otel/collector.yaml b/src/assets/templates/bedrock-managed-agents/otel/collector.yaml new file mode 100644 index 0000000000..84daf8e075 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/otel/collector.yaml @@ -0,0 +1,69 @@ +# The CloudWatch agent runs this OpenTelemetry Collector configuration. It receives OTLP from +# the exec-server, puts the runtime session ID on each span and log, and signs each request +# with the ACR role. The exec-server sends the log group headers that the Runtime sets in +# OTEL_EXPORTER_OTLP_TRACES_HEADERS and OTEL_EXPORTER_OTLP_LOGS_HEADERS, and the collector +# sends them on to X-Ray and CloudWatch Logs. +receivers: + otlp: + protocols: + http: + endpoint: 127.0.0.1:4318 + include_metadata: true +processors: + attributes: + actions: + - key: session.id + value: ${env:AGENTCORE_RUNTIME_SID} + action: upsert +exporters: + otlphttp/traces: + traces_endpoint: https://xray.${env:AWS_REGION}.amazonaws.com/v1/traces + auth: + authenticator: headers_setter/traces + otlphttp/logs: + logs_endpoint: https://logs.${env:AWS_REGION}.amazonaws.com/v1/logs + auth: + authenticator: headers_setter/logs +extensions: + sigv4auth/xray: + region: ${env:AWS_REGION} + service: xray + sigv4auth/logs: + region: ${env:AWS_REGION} + service: logs + headers_setter/traces: + additional_auth: sigv4auth/xray + headers: + - key: x-aws-log-group + from_context: x-aws-log-group + action: upsert + - key: x-aws-log-stream + from_context: x-aws-log-stream + action: upsert + headers_setter/logs: + additional_auth: sigv4auth/logs + headers: + - key: x-aws-log-group + from_context: x-aws-log-group + action: upsert + - key: x-aws-log-stream + from_context: x-aws-log-stream + action: upsert + - key: x-aws-metric-namespace + from_context: x-aws-metric-namespace + action: upsert +service: + extensions: + - sigv4auth/xray + - sigv4auth/logs + - headers_setter/traces + - headers_setter/logs + pipelines: + traces: + receivers: [otlp] + processors: [attributes] + exporters: [otlphttp/traces] + logs: + receivers: [otlp] + processors: [attributes] + exporters: [otlphttp/logs] diff --git a/src/assets/templates/bedrock-managed-agents/plugins/acr-report/.codex-plugin/plugin.json b/src/assets/templates/bedrock-managed-agents/plugins/acr-report/.codex-plugin/plugin.json new file mode 100644 index 0000000000..a6570c2e25 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/plugins/acr-report/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "acr-report", + "version": "0.1.0", + "description": "Report the Python version, user, and working directory of the ACR.", + "skills": "./skills/" +} diff --git a/src/assets/templates/bedrock-managed-agents/plugins/acr-report/skills/acr-report/SKILL.md b/src/assets/templates/bedrock-managed-agents/plugins/acr-report/skills/acr-report/SKILL.md new file mode 100644 index 0000000000..0a2922b247 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/plugins/acr-report/skills/acr-report/SKILL.md @@ -0,0 +1,16 @@ +--- +name: acr-report +description: Save a report of the Python version, user ID, and working directory of the ACR in the workspace. +--- + +Run these commands in the workspace directory: + +- `python3 --version` +- `id -u` +- `pwd` + +Save the output as `acr-report.txt` in the workspace. The first line of the +file is `# ACR report`, and each command and its output follow it. Read the +file back and report its path. + +If a command fails, put its error in the report. Do not change the commands. diff --git a/src/assets/templates/bedrock-managed-agents/pyproject.toml b/src/assets/templates/bedrock-managed-agents/pyproject.toml new file mode 100644 index 0000000000..9bbb0ffa23 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore Runtime environment for Bedrock Managed Agents" +readme = "README.md" +dependencies = [ + "aws-opentelemetry-distro", + "bedrock-agentcore", +] + +# The dependencies of client.py. The image build runs `uv sync --no-dev`. +[dependency-groups] +dev = [ + "aws-bedrock-token-generator>=1.1.0", + "openai>=3.16.2", +] + +[tool.uv] +package = false diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 6812a78af6..878a132f77 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -46,6 +46,7 @@ const AGENT_PYTHON_STRANDS_CONTAINER = resolveRuntimeTemplateShortcut( const AGENT_TYPESCRIPT_STRANDS = resolveRuntimeTemplateShortcut("agent-typescript-strands"); const A2A_PYTHON_STRANDS = resolveRuntimeTemplateShortcut("a2a-python-strands"); const AGENT_PYTHON_LANGCHAIN = resolveRuntimeTemplateShortcut("agent-python-langchain"); +const BEDROCK_MANAGED_AGENTS = resolveRuntimeTemplateShortcut("bedrock-managed-agents"); function withTemplateProfile( input: ScaffoldRuntimeInput, @@ -288,6 +289,54 @@ describe("FsProjectManager.create", () => { expect(spec.runtimes[0]).toMatchObject({ build: "Container", dockerfile: "Dockerfile" }); }); + test("scaffolds the Bedrock Managed Agents environment and runtime defaults", async () => { + const directory = await inTempDirectory(); + const setup = manager(); + await runCreate(setup.manager, { + name: "example", + scaffoldRuntimeInput: BEDROCK_MANAGED_AGENTS, + }); + + const projectRoot = join(directory, "example"); + const appDir = join(projectRoot, "app", "bedrock_managed_agents"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes).toEqual([ + { + name: "bedrock_managed_agents", + build: "Container", + entrypoint: "lifecycle/server.py", + codeLocation: "app/bedrock_managed_agents", + dockerfile: "Dockerfile", + additionalPolicies: ["bma-acr-policy.json"], + protocol: "HTTP", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + tags: { "agentcore:template": "BedrockManagedAgents" }, + }, + ]); + expect(spec.memories ?? []).toEqual([]); + expect(await Bun.file(join(appDir, "lifecycle", "server.py")).exists()).toBe(true); + expect(await Bun.file(join(appDir, "otel", "collector.yaml")).exists()).toBe(true); + expect( + await Bun.file( + join(appDir, "plugins", "acr-report", "skills", "acr-report", "SKILL.md"), + ).exists(), + ).toBe(true); + expect(await Bun.file(join(appDir, "pyproject.toml")).text()).toContain( + 'name = "bedrock_managed_agents"', + ); + expect(setup.commands).toEqual([ + { + command: ["npm", "install", "--loglevel=http"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, + { command: ["git", "init"], cwd: projectRoot }, + ]); + expect(setup.checkedTools).toEqual(["npm", "git"]); + }); + test("refuses to overwrite an existing project", async () => { await inTempDirectory(); const input: CreateProjectInput = { diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index f7b22eaeff..ec3cc5c530 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -330,6 +330,23 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa spec: { runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }] }, }; }, + [buildResolverKey("bedrock-managed-agents", "Python", "HTTP")]: async ( + input: RuntimeResourceConfig, + ) => { + const tree = await FsTreeNode.fromAssetSource( + { assetSource }, + { assetDir: "templates/bedrock-managed-agents" }, + { + rootDirName: input.name, + transformContent: (raw) => + templateRenderer.render(raw, { name: toPythonPackageName(input.name) }), + }, + ); + return { + tree, + spec: { runtimes: [{ ...buildRuntimeSpec(input), protocol: "HTTP" as const }] }, + }; + }, [buildResolverKey("none", "Python", "MCP")]: async (input: RuntimeResourceConfig) => { if (input.scaffoldRuntimeInput.modelProvider !== undefined) throw new InputValidationError("an MCP runtime does not use a model provider"); diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 8ebddafa05..53984fed35 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -104,6 +104,18 @@ describe("project add runtime", () => { build: "CodeZip", protocol: "AGUI", }, + "bedrock-managed-agents template preset": { + build: "Container", + entrypoint: "lifecycle/server.py", + dockerfile: "Dockerfile", + protocol: "HTTP", + additionalPolicies: ["bma-acr-policy.json"], + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + tags: { "agentcore:template": "BedrockManagedAgents" }, + }, "all infrastructure flags": { description: "Configured runtime", executionRoleArn: "arn:aws:iam::123456789012:role/MyRole", @@ -163,6 +175,10 @@ describe("project add runtime", () => { "agui-python-strands template preset", ["--name", "my_agui", "--template", "agui-python-strands"], ], + [ + "bedrock-managed-agents template preset", + ["--name", "my_bma", "--template", "bedrock-managed-agents"], + ], [ "agent-python-strands with session, EFS, and S3 mounts", ["--name", "fs_agent", "--template", "agent-python-strands", ...mounts], @@ -270,12 +286,69 @@ describe("project add runtime", () => { const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); const runtime = spec.runtimes.find((candidate: { name: string }) => candidate.name === name); expect(runtime).toMatchObject({ entrypoint: "main.py", ...expectedSpecByLabel[label] }); - expect(await Bun.file(join(projectRoot, "app", name, "main.py")).exists()).toBe(true); - const isContainer = flags.some((flag) => flag.endsWith("-container")); + const isBma = flags.includes("bedrock-managed-agents"); + expect( + await Bun.file( + join(projectRoot, "app", name, isBma ? "lifecycle/server.py" : "main.py"), + ).exists(), + ).toBe(true); + const isContainer = flags.some( + (flag) => flag.endsWith("-container") || flag === "bedrock-managed-agents", + ); expect(runtime.runtimeVersion).toBe(isContainer ? undefined : "PYTHON_3_14"); expect(await Bun.file(join(projectRoot, "app", name, "Dockerfile")).exists()).toBe(isContainer); expect(await Bun.file(join(projectRoot, "app", name, ".dockerignore")).exists()).toBe( - isContainer, + isContainer && !isBma, + ); + }); + + test("Bedrock Managed Agents defers local Python dependency setup", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const { core } = await run([ + "add", + "runtime", + "--name", + "my_bma", + "--template", + "bedrock-managed-agents", + ]); + + expect(core.projectCommands).toEqual([]); + expect(await Bun.file(join(projectRoot, "app", "my_bma", "pyproject.toml")).exists()).toBe( + true, + ); + }); + + test("Bedrock Managed Agents keeps infrastructure overrides and its required policy", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + await run([ + "add", + "runtime", + "--name", + "my_bma", + "--template", + "bedrock-managed-agents", + "--lifecycle-configuration", + '{"idleRuntimeSessionTimeout":300,"maxLifetime":3600}', + "--additional-policies", + "custom-policy.json", + "--tags", + '{"team":"agents"}', + ]); + + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes).toContainEqual( + expect.objectContaining({ + name: "my_bma", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 300, + maxLifetime: 3600, + }, + additionalPolicies: ["bma-acr-policy.json", "custom-policy.json"], + tags: { "agentcore:template": "BedrockManagedAgents", team: "agents" }, + }), ); }); @@ -287,6 +360,7 @@ describe("project add runtime", () => { ["agent-typescript-vercel", []], ["mcp-python-fastmcp", []], ["agui-python-strands", ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"]], + ["bedrock-managed-agents", []], ])("%s ships with its pre-configured memory", async (templateName, expectedStrategies) => { const { projectRoot, cleanup } = await initProject(); cleanups.push(cleanup); @@ -420,6 +494,10 @@ describe("project add runtime", () => { "Anthropic", ], ], + [ + "--model-provider is not valid with the bedrock-managed-agents template", + ["--name", "my_bma", "--template", "bedrock-managed-agents", "--model-provider", "Anthropic"], + ], [ "--model-provider without a template requires agent-python-strands", ["--name", "my_agent", "--model-provider", "Anthropic"], diff --git a/src/handlers/project/add/runtime/runtime.screen.test.tsx b/src/handlers/project/add/runtime/runtime.screen.test.tsx index c9da4cc4fa..023c671910 100644 --- a/src/handlers/project/add/runtime/runtime.screen.test.tsx +++ b/src/handlers/project/add/runtime/runtime.screen.test.tsx @@ -133,6 +133,35 @@ describe("project add runtime wizard", () => { r.unmount(); }); + test("scaffolds the Bedrock Managed Agents environment", async () => { + const projectRoot = await inProject(); + const r = renderScreen("/agentcore/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write("bma_environment"); + await r.press("return"); + + await waitForText(r.lastFrame, "choose a template"); + await selectTemplate(r, "bedrock-managed-agents"); + await r.press("return"); + + await waitForFlatText(r.lastFrame, "build Container"); + await r.press("return"); + await waitForText(r.lastFrame, "added runtime 'bma_environment' to 'TestProject'"); + + expect(await runtimeInSpec(projectRoot, "bma_environment")).toMatchObject({ + build: "Container", + entrypoint: "lifecycle/server.py", + additionalPolicies: ["bma-acr-policy.json"], + }); + expect( + await Bun.file( + join(projectRoot, "app", "bma_environment", "lifecycle", "server.py"), + ).exists(), + ).toBe(true); + r.unmount(); + }); + test("a blank name is refused", async () => { await inProject(); const r = renderScreen("/agentcore/add/runtime"); diff --git a/src/handlers/project/bmaProfile.ts b/src/handlers/project/bmaProfile.ts new file mode 100644 index 0000000000..ff65f5cc90 --- /dev/null +++ b/src/handlers/project/bmaProfile.ts @@ -0,0 +1,16 @@ +import type { RuntimeTemplateProfile } from "./templateProfile"; + +export const BMA_TEMPLATE_PROFILE = { + usesModel: false, + dependencySetup: "deferred", + runtime: { + entrypoint: "lifecycle/server.py", + dockerfile: "Dockerfile", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + additionalPolicies: ["bma-acr-policy.json"], + tags: { "agentcore:template": "BedrockManagedAgents" }, + }, +} as const satisfies RuntimeTemplateProfile; diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index 27aad03774..543935746f 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -388,6 +388,7 @@ describe("project create wizard", () => { expect(r.lastFrame()).toContain("● agent-python-strands"); expect(r.lastFrame()).not.toContain("agent-python-strands (recommended)"); expect(r.lastFrame()).toContain("agent-python-strands-container"); + expect(r.lastFrame()).toContain("bedrock-managed-agents"); await r.press("return"); // No memory step: memory is no longer a choice, so review follows directly. @@ -433,6 +434,7 @@ describe("project create wizard", () => { await waitForText(r.lastFrame, "choose a template"); await r.press("down"); // agent-python-strands-container await r.press("down"); // agent-python-langchain + await r.press("down"); // bedrock-managed-agents await r.press("down"); // agent-python-minimal await waitForText(r.lastFrame, "● agent-python-minimal "); await r.press("return"); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ffb15d7e3f..907fbbdc41 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -351,6 +351,51 @@ describe("project create", () => { expect(await Bun.file(join(runtimeRoot, ".dockerignore")).exists()).toBe(true); }); + test("scaffolds a Bedrock Managed Agents execution environment", async () => { + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); + const { core } = await run([ + "create", + "--name", + "BmaProject", + "--template", + "bedrock-managed-agents", + ]); + + const projectRoot = join(directory, "BmaProject"); + const runtimeRoot = join(projectRoot, "app", "agent"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes).toEqual([ + { + name: "agent", + build: "Container", + entrypoint: "lifecycle/server.py", + codeLocation: "app/agent", + dockerfile: "Dockerfile", + additionalPolicies: ["bma-acr-policy.json"], + protocol: "HTTP", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + tags: { "agentcore:template": "BedrockManagedAgents" }, + }, + ]); + expect(spec.memories ?? []).toEqual([]); + expect(await Bun.file(join(runtimeRoot, "lifecycle", "server.py")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, "client.py")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, "Dockerfile")).text()).toContain( + "RUN uv sync --no-dev", + ); + expect(core.projectCommands).toEqual([ + { + command: ["npm", "install", "--loglevel=http"], + cwd: join(projectRoot, "agentcore", "cdk"), + }, + { command: ["git", "init"], cwd: projectRoot }, + ]); + }); + test("omits the Dockerfile from a CodeZip strands template", async () => { const { path: directory, cleanup } = await inTempDirectory(); cleanups.push(cleanup); diff --git a/src/handlers/project/shortcuts.test.ts b/src/handlers/project/shortcuts.test.ts index 3f3e93d975..299f4cc8b7 100644 --- a/src/handlers/project/shortcuts.test.ts +++ b/src/handlers/project/shortcuts.test.ts @@ -14,6 +14,7 @@ describe("template order", () => { "agent-python-strands", "agent-python-strands-container", "agent-python-langchain", + "bedrock-managed-agents", "agent-python-minimal", "agent-typescript-strands", "agent-typescript-vercel", @@ -25,6 +26,31 @@ describe("template order", () => { }); }); +test("the Bedrock Managed Agents shortcut selects its environment profile", () => { + expect(RUNTIME_TEMPLATE_SHORTCUTS["bedrock-managed-agents"]).toMatchObject({ + build: "Container", + language: "Python", + framework: "bedrock-managed-agents", + protocol: "HTTP", + includesMemory: false, + supportsModelProviderOverride: false, + profile: { + usesModel: false, + dependencySetup: "deferred", + runtime: { + entrypoint: "lifecycle/server.py", + dockerfile: "Dockerfile", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 1800, + maxLifetime: 28800, + }, + additionalPolicies: ["bma-acr-policy.json"], + tags: { "agentcore:template": "BedrockManagedAgents" }, + }, + }, + }); +}); + describe("template parameter help", () => { test("lists every Runtime template with its registry description", () => { const help = formatTemplateParameterHelp(); diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts index 58bc839f50..b6a1fb64db 100644 --- a/src/handlers/project/shortcuts.ts +++ b/src/handlers/project/shortcuts.ts @@ -8,6 +8,7 @@ import { import { InputValidationError } from "../../errors"; import { ScaffoldRuntimeInputSchema, type ModelProvider, type ScaffoldRuntimeInput } from "./types"; import type { RuntimeTemplateProfile } from "./templateProfile"; +import { BMA_TEMPLATE_PROFILE } from "./bmaProfile"; /** The default memory that templates ship with. */ export function getDefaultMemorySpec(runtimeName: string): Memory { @@ -45,9 +46,9 @@ type RuntimeTemplateShortcut = { }; /** - * The runtime templates. Only agent-python-strands offers a container build (its - * `-container` shortcut renders the same source with a Dockerfile); every other - * template is CodeZip-only. + * The runtime templates. agent-python-strands offers both CodeZip and container + * builds; Bedrock Managed Agents is a specialized container-only environment. + * Every other template is CodeZip-only. */ export const RUNTIME_TEMPLATE_SHORTCUTS = { "agent-python-minimal": { @@ -116,6 +117,17 @@ export const RUNTIME_TEMPLATE_SHORTCUTS = { supportsModelProviderOverride: false, runtimeVersion: "NODE_22", }, + "bedrock-managed-agents": { + runtimeName: "bedrock_managed_agents", + description: "Codex execution environment for Bedrock Managed Agents", + build: "Container", + language: "Python", + framework: "bedrock-managed-agents", + protocol: "HTTP", + includesMemory: false, + supportsModelProviderOverride: false, + profile: BMA_TEMPLATE_PROFILE, + }, "mcp-python-fastmcp": { runtimeName: "mcp_python_fastmcp", description: "MCP server exposing tools with FastMCP", @@ -169,7 +181,8 @@ const FRAMEWORK_ORDER: Record = { strands: 0, langchain: 1, vercelai: 2, - none: 3, + "bedrock-managed-agents": 3, + none: 4, }; const BUILD_ORDER: Record = { CodeZip: 0, Container: 1 }; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 68c5f1f911..40322a7fb6 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -48,7 +48,7 @@ export const ScaffoldRuntimeInputSchema = z runtimeName: AgentNameSchema, build: BuildTypeSchema, language: z.enum(["Python", "TypeScript"]), - framework: z.enum(["strands", "langchain", "vercelai", "none"]), + framework: z.enum(["strands", "langchain", "vercelai", "bedrock-managed-agents", "none"]), protocol: ProtocolModeSchema.optional(), modelProvider: ModelProviderSchema.optional(), modelId: z.string().min(1).optional(), From 149882a8a62a841f5ccfa7384b90675440578177 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 14:40:35 -0400 Subject: [PATCH 3/9] docs(project): update ReadMe with BMA template --- README.md | 12 ++++++++++++ .../templates/bedrock-managed-agents/README.md | 13 +++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f3068675bb..1509acda6a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ Run this alternative from outside an existing project: agentcore create --name MyAgent --template agent-python-strands ``` +To create an execution environment for Bedrock Managed Agents: + +```bash +agentcore create --name MyManagedAgent --template bedrock-managed-agents +``` + +Bedrock Managed Agents runs the agent loop and sends lifecycle and command requests to the +generated AgentCore Runtime. The Runtime is a container environment for Codex rather than an agent +that invokes a model itself, so it has no model-provider or memory configuration. Its dependencies +are installed when the container image is built. The generated Runtime README covers deployment, +the sample client, persistence, observability, and adding skills. + ## Command Surface Project commands manage local project specifications and their deployments. diff --git a/src/assets/templates/bedrock-managed-agents/README.md b/src/assets/templates/bedrock-managed-agents/README.md index df09959dbe..4e61e75cee 100644 --- a/src/assets/templates/bedrock-managed-agents/README.md +++ b/src/assets/templates/bedrock-managed-agents/README.md @@ -36,7 +36,9 @@ image build downloads Codex and Python from the internet, so a build in VPC mode - No session storage. Session storage is only for a microVM Runtime, so without it the same settings work on a capacity provider. -A value that you give to `agentcore create` replaces the value above. +To customize these settings after creation, edit the Runtime entry in `agentcore/agentcore.json`. +When adding this environment to an existing project, `agentcore add runtime` also accepts +`--lifecycle-configuration` and `--filesystem-configurations`. The server keeps the connection state in `state.json` in `BMA_STATE_DIR`. The default is `/home/app/.bma`. The client sets the workspace in `workspace_directory` when it creates the session. `client.py` uses `/home/app/workspace`. The @@ -47,7 +49,14 @@ server returns status 400. If the server cannot create the directory, it returns Files in the home directory do not stay after an idle stop. On a microVM Runtime, to keep the files and the connection state after an idle stop, put the home directory on session storage: -1. Add session storage with `--session-storage-mount-path /mnt/home`. +1. Add session storage to the Runtime in `agentcore/agentcore.json`: + + ```json + "filesystemConfigurations": [ + { "sessionStorage": { "mountPath": "/mnt/home" } } + ] + ``` + 2. Set `BMA_HOME_DIR` to `/mnt/home` in `envVars`. Then `state.json` is in `/mnt/home/.bma`, and `CODEX_HOME` is `/mnt/home/.codex`. 3. Set `WORKSPACE_DIRECTORY` in `client.py` to `/mnt/home/workspace`. From 283b04d22eb4ef17da959b44a10f919793fde2e2 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 15:07:37 -0400 Subject: [PATCH 4/9] fix(project): warn about BMA permissions for existing roles --- .../bedrock-managed-agents/README.md | 3 ++ .../project/add/runtime/index.test.ts | 31 +++++++++++++++++++ src/handlers/project/add/runtime/index.ts | 5 +++ src/handlers/project/bmaProfile.ts | 14 +++++++-- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/assets/templates/bedrock-managed-agents/README.md b/src/assets/templates/bedrock-managed-agents/README.md index 4e61e75cee..a5b74a3c33 100644 --- a/src/assets/templates/bedrock-managed-agents/README.md +++ b/src/assets/templates/bedrock-managed-agents/README.md @@ -40,6 +40,9 @@ To customize these settings after creation, edit the Runtime entry in `agentcore When adding this environment to an existing project, `agentcore add runtime` also accepts `--lifecycle-configuration` and `--filesystem-configurations`. +If the Runtime has an `executionRoleArn`, AgentCore CDK cannot attach `bma-acr-policy.json` to that imported role. +Grant the role `bedrock-mantle:RegisterEnvironment` and `bedrock-mantle:ConnectEnvironment` before deploying. + The server keeps the connection state in `state.json` in `BMA_STATE_DIR`. The default is `/home/app/.bma`. The client sets the workspace in `workspace_directory` when it creates the session. `client.py` uses `/home/app/workspace`. The server creates that directory and runs the agent commands in it. If the activate call has no workspace directory, the diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 53984fed35..2b00742ceb 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -352,6 +352,37 @@ describe("project add runtime", () => { ); }); + test("Bedrock Managed Agents warns when an existing execution role needs its permissions", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const roleArn = "arn:aws:iam::111122223333:role/ExistingBmaRole"; + + const { io } = await run([ + "add", + "runtime", + "--name", + "my_bma", + "--template", + "bedrock-managed-agents", + "--role-arn", + roleArn, + "--json", + ]); + + expect(JSON.parse(io.stdout()).notes).toEqual([ + expect.stringContaining("AgentCore CDK cannot attach bma-acr-policy.json"), + ]); + expect(io.stderr()).not.toContain("bma-acr-policy.json"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes).toContainEqual( + expect.objectContaining({ + name: "my_bma", + executionRoleArn: roleArn, + additionalPolicies: ["bma-acr-policy.json"], + }), + ); + }); + test.each<[string, string[]]>([ ["agent-python-strands", ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"]], ["a2a-python-strands", ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"]], diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 0c5077fc36..a7114677be 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -23,6 +23,7 @@ import { } from "../../importBedrockAgent"; import { RegionKey } from "../../../keys"; import { addProjectResource, requireDeployedNameFits } from "../shared"; +import { BMA_CUSTOM_EXECUTION_ROLE_WARNING, BMA_TEMPLATE_NAME } from "../../bmaProfile"; const CONFIGURATION = "Configuration:"; const ENVIRONMENT = "Environment:"; @@ -274,6 +275,10 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => }) : resolveRuntimeTemplateShortcut("agent-python-minimal", { runtimeName: flags.name }); + if (scaffoldRuntimeInput.framework === BMA_TEMPLATE_NAME && flags["role-arn"] !== undefined) { + notes.push(BMA_CUSTOM_EXECUTION_ROLE_WARNING); + } + const inputEnvironmentVariables = parseJsonFlag>( "environment-variables", flags["environment-variables"], diff --git a/src/handlers/project/bmaProfile.ts b/src/handlers/project/bmaProfile.ts index ff65f5cc90..5a7881d28b 100644 --- a/src/handlers/project/bmaProfile.ts +++ b/src/handlers/project/bmaProfile.ts @@ -1,5 +1,15 @@ import type { RuntimeTemplateProfile } from "./templateProfile"; +export const BMA_TEMPLATE_NAME = "bedrock-managed-agents"; +export const BMA_POLICY_FILE = "bma-acr-policy.json"; +export const BMA_TEMPLATE_TAG_KEY = "agentcore:template"; +export const BMA_TEMPLATE_TAG_VALUE = "BedrockManagedAgents"; + +export const BMA_CUSTOM_EXECUTION_ROLE_WARNING = + `Warning: --role-arn uses an existing execution role, so AgentCore CDK cannot attach ` + + `${BMA_POLICY_FILE}. Ensure the role grants bedrock-mantle:RegisterEnvironment and ` + + `bedrock-mantle:ConnectEnvironment before deploying.`; + export const BMA_TEMPLATE_PROFILE = { usesModel: false, dependencySetup: "deferred", @@ -10,7 +20,7 @@ export const BMA_TEMPLATE_PROFILE = { idleRuntimeSessionTimeout: 1800, maxLifetime: 28800, }, - additionalPolicies: ["bma-acr-policy.json"], - tags: { "agentcore:template": "BedrockManagedAgents" }, + additionalPolicies: [BMA_POLICY_FILE], + tags: { [BMA_TEMPLATE_TAG_KEY]: BMA_TEMPLATE_TAG_VALUE }, }, } as const satisfies RuntimeTemplateProfile; From 516fff87bfe8dfd6aa72addea10dbff9d43d28c9 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 15:08:15 -0400 Subject: [PATCH 5/9] fix(project): block BMA in China regions --- src/core/project/manager.test.ts | 46 +++++++++++++++++- src/core/project/manager.tsx | 26 ++++++++++ .../project/create/create.screen.test.tsx | 31 ++++++++++++ src/handlers/project/create/index.ts | 48 +++++-------------- src/handlers/project/create/region.ts | 35 ++++++++++++++ src/handlers/project/create/screen.tsx | 6 ++- src/handlers/project/project.test.ts | 16 +++++++ 7 files changed, 169 insertions(+), 39 deletions(-) create mode 100644 src/handlers/project/create/region.ts diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 878a132f77..3904b12604 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -15,6 +15,7 @@ import { credentialEnvVarName } from "../../projectSchemas/credential"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { ENV_LOCAL_RELATIVE_PATH } from "./envLocal"; import { + BMA_CN_MESSAGE, cnUnsupportedResourceMessage, FsProjectManager, LITELLM_BEDROCK_MODEL_ID_CN_MESSAGE, @@ -703,7 +704,11 @@ describe("FsProjectManager.addResource", () => { describe("China (aws-cn) deployment targets", () => { const MCP_PYTHON_FASTMCP = resolveRuntimeTemplateShortcut("mcp-python-fastmcp"); - async function projectWithTarget(region: string, missingToolAfterCreate?: string) { + async function projectWithTarget( + region: string, + missingToolAfterCreate?: string, + scaffoldRuntimeInput: ScaffoldRuntimeInput = AGENT_PYTHON, + ) { const checkedTools: string[] = []; const deployCalls: { project: Project; input: DeployBackendInput }[] = []; let missingTool: string | undefined; @@ -734,7 +739,7 @@ describe("FsProjectManager.addResource", () => { }); const { project } = await runCreate(subject, { name: "example", - scaffoldRuntimeInput: AGENT_PYTHON, + scaffoldRuntimeInput, skipInstall: true, skipGit: true, }); @@ -766,6 +771,28 @@ describe("FsProjectManager.addResource", () => { expect(existsSync(runtimePath)).toBe(false); }); + test("rejects a Bedrock Managed Agents template before scaffolding", async () => { + await inTempDirectory(); + const { subject, project, checkedTools } = await projectWithTarget("cn-north-1"); + const runtimePath = join(project.rootPath, "app", "cn_bma"); + + await expect( + runAdd(subject, project, { + resourceType: "runtime", + resourceConfig: { + name: "cn_bma", + scaffoldRuntimeInput: { + ...BEDROCK_MANAGED_AGENTS, + runtimeName: "cn_bma", + }, + }, + }), + ).rejects.toThrow(new RegionUnsupportedFeatureError(BMA_CN_MESSAGE)); + + expect(checkedTools).toEqual([]); + expect(existsSync(runtimePath)).toBe(false); + }); + test("lets a provider-free template past the partition gate", async () => { await inTempDirectory(); const { subject, project } = await projectWithTarget("cn-north-1", "uv"); @@ -911,6 +938,21 @@ describe("FsProjectManager.addResource", () => { expect(deployCalls).toEqual([]); }); + test("deploy to a China target hard-fails a Bedrock Managed Agents runtime", async () => { + await inTempDirectory(); + const { subject, project, deployCalls } = await projectWithTarget( + "cn-north-1", + undefined, + BEDROCK_MANAGED_AGENTS, + ); + + const { error } = await deployOutcome(subject, project); + expect(error).toBeInstanceOf(RegionUnsupportedFeatureError); + expect(String(error)).toContain("runtime 'bedrock_managed_agents'"); + expect(String(error)).toContain(BMA_CN_MESSAGE); + expect(deployCalls).toEqual([]); + }); + test("deploy to a China target proceeds past the gate for LiteLLM", async () => { await inTempDirectory(); const { subject, project, deployCalls } = await projectWithTarget("cn-north-1"); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index bca0a598e9..dba6eeeb02 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -93,6 +93,12 @@ import { HandlebarsTemplateRenderer } from "./templates/renderer"; import type { CreateCloudFormationClient } from "../types"; import type { CoreIdentityClient } from "../../handlers/identity/types"; import { templateManagesDependencies } from "../../handlers/project/templateProfile"; +import { + BMA_POLICY_FILE, + BMA_TEMPLATE_NAME, + BMA_TEMPLATE_TAG_KEY, + BMA_TEMPLATE_TAG_VALUE, +} from "../../handlers/project/bmaProfile"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; @@ -112,6 +118,11 @@ export const MODEL_PROVIDER_RUNTIMES_CN_MESSAGE = "or use --template agent-python-strands --model-provider litellm --model-id ."; +/** Shown when a Bedrock Managed Agents environment targets the aws-cn partition. */ +export const BMA_CN_MESSAGE = + "Bedrock Managed Agents is not available in China regions (cn-north-1, cn-northwest-1). " + + "Choose a supported commercial or GovCloud region."; + /** * Shown when a harness is created in or deployed to a China (aws-cn) region. * Harnesses are not part of the China launch: their model providers and IAM @@ -457,6 +468,9 @@ export class FsProjectManager implements ProjectManager { if (input.resourceType === "runtime") { const { framework, modelProvider, modelId, memory } = input.resourceConfig.scaffoldRuntimeInput; + if (framework === BMA_TEMPLATE_NAME) { + throw new RegionUnsupportedFeatureError(BMA_CN_MESSAGE); + } if (framework !== "none") { if ((modelProvider ?? "Bedrock") !== "LiteLLM") { throw new RegionUnsupportedFeatureError(MODEL_PROVIDER_RUNTIMES_CN_MESSAGE); @@ -1152,6 +1166,18 @@ export class FsProjectManager implements ProjectManager { // modelProvider (BYO, provider-free, hand-edited, or scaffolded by an // older CLI) cannot be classified and only get an informational note. if (isChinaRegion(target.region)) { + const bmaRuntimes = project.spec.runtimes.filter( + (runtime) => + runtime.tags?.[BMA_TEMPLATE_TAG_KEY] === BMA_TEMPLATE_TAG_VALUE || + runtime.additionalPolicies?.includes(BMA_POLICY_FILE), + ); + if (bmaRuntimes.length > 0) { + throw new RegionUnsupportedFeatureError( + `Cannot deploy to China region ${target.region}: ` + + `${bmaRuntimes.map((runtime) => `runtime '${runtime.name}'`).join(", ")} uses ` + + `Bedrock Managed Agents. ${BMA_CN_MESSAGE}`, + ); + } const blocked = project.spec.runtimes.filter( (runtime) => runtime.modelProvider !== undefined && diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index 543935746f..afdb5ce6de 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -21,6 +21,7 @@ import type { AppIO } from "../../../io"; import { resolveRuntimeTemplateShortcut } from "../shortcuts"; import type { CreateProjectInput } from "../types"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { RegionKey } from "../../keys"; const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); @@ -419,6 +420,36 @@ describe("project create wizard", () => { r.unmount(); }, 10000); + test("the wizard rejects Bedrock Managed Agents in a China region before create", async () => { + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); + const core = new TestCoreClient(); + const inputs = spyOnCreate(core); + const r = renderScreen("/agentcore/create", { + core, + withContext: (ctx) => ctx.withValue(RegionKey, "cn-north-1"), + }); + + await waitForText(r.lastFrame, "name your project"); + await r.write("CnBma"); + await r.press("return"); + await waitForText(r.lastFrame, "what kind of agent to start with?"); + await r.press("return"); + await waitForText(r.lastFrame, "choose a template"); + await r.press("down"); // agent-python-strands-container + await r.press("down"); // agent-python-langchain + await r.press("down"); // bedrock-managed-agents + await waitForText(r.lastFrame, "● bedrock-managed-agents"); + await r.press("return"); + await waitForText(r.lastFrame, "this project will be created"); + await r.press("return"); + + await waitForText(r.lastFrame, "Bedrock Managed Agents is not available in China regions"); + expect(inputs).toEqual([]); + expect(existsSync(join(directory, "CnBma"))).toBe(false); + r.unmount(); + }); + test("template flow: the minimal template scaffolds without memory", async () => { const { path: directory, cleanup } = await inTempDirectory(); cleanups.push(cleanup); diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index a5a68a82e5..cba367749e 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -25,18 +25,13 @@ import { HarnessSpecSchema, type HarnessModelProvider, } from "../../../projectSchemas/harness"; -import { InputValidationError, RegionUnsupportedFeatureError } from "../../../errors"; +import { InputValidationError } from "../../../errors"; import { isChinaRegion } from "../../../core/partition"; -import { - HARNESS_CN_MESSAGE, - LITELLM_BEDROCK_MODEL_ID_CN_MESSAGE, - LITELLM_MODEL_ID_REQUIRED_CN_MESSAGE, - MEMORY_STRIPPED_CN_MESSAGE, - MODEL_PROVIDER_RUNTIMES_CN_MESSAGE, -} from "../../../core/project/manager"; +import { MEMORY_STRIPPED_CN_MESSAGE } from "../../../core/project/manager"; import { JsonKey, RegionKey } from "../../keys"; import { renderResult } from "../../utils"; import { projectReference, type ProjectMutationResult } from "../output"; +import { validateCreateRegionSupport } from "./region"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; @@ -122,11 +117,6 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = let createInput: CreateProjectInput; if (template === undefined) { - // The default (no --template) creates a harness project, which is not - // part of the China launch. - if (isChinaRegion(ctx.require(RegionKey))) { - throw new RegionUnsupportedFeatureError(HARNESS_CN_MESSAGE); - } createInput = { ...base, scaffoldHarnessInput: resolveScaffoldHarnessInput({ name }) }; } else if (template === EMPTY_TEMPLATE_NAME) { createInput = { ...base }; @@ -139,32 +129,18 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = modelId: flags["model-id"], apiKey, }); - // Apply the China gate at creation when the command's resolved region - // (--region flag, env, or profile) already says aws-cn, so the most - // common workflow fails before scaffolding instead of at deploy. - if (isChinaRegion(ctx.require(RegionKey))) { - if (scaffoldRuntimeInput.framework !== "none") { - if ((scaffoldRuntimeInput.modelProvider ?? "Bedrock") !== "LiteLLM") { - throw new RegionUnsupportedFeatureError(MODEL_PROVIDER_RUNTIMES_CN_MESSAGE); - } - if (scaffoldRuntimeInput.modelId === undefined) { - throw new RegionUnsupportedFeatureError(LITELLM_MODEL_ID_REQUIRED_CN_MESSAGE); - } - if (scaffoldRuntimeInput.modelId.startsWith("bedrock/")) { - throw new RegionUnsupportedFeatureError(LITELLM_BEDROCK_MODEL_ID_CN_MESSAGE); - } - } - // AgentCore Memory is not available in China regions; scaffold - // without the template's default memory (the rendered code degrades - // to no memory until its env var appears). - if (scaffoldRuntimeInput.memory !== undefined) { - scaffoldRuntimeInput.memory = undefined; - config.io.stderr.write(`${MEMORY_STRIPPED_CN_MESSAGE}\n`); - } - } createInput = { ...base, scaffoldRuntimeInput }; } + const region = ctx.require(RegionKey); + validateCreateRegionSupport(createInput, region); + // AgentCore Memory is not available in China regions; scaffold without + // the template's default memory after all hard restrictions have passed. + if (isChinaRegion(region) && createInput.scaffoldRuntimeInput?.memory !== undefined) { + createInput.scaffoldRuntimeInput.memory = undefined; + config.io.stderr.write(`${MEMORY_STRIPPED_CN_MESSAGE}\n`); + } + // Same driver as build and deploy: a live step list in a TTY, and the previous plain // line-per-step output when stderr is not a TTY or --json wants no ANSI on it. const project = await runWithProgress(config.projectManager.create(createInput), { diff --git a/src/handlers/project/create/region.ts b/src/handlers/project/create/region.ts new file mode 100644 index 0000000000..ef695af01b --- /dev/null +++ b/src/handlers/project/create/region.ts @@ -0,0 +1,35 @@ +import { isChinaRegion } from "../../../core/partition"; +import { + BMA_CN_MESSAGE, + HARNESS_CN_MESSAGE, + LITELLM_BEDROCK_MODEL_ID_CN_MESSAGE, + LITELLM_MODEL_ID_REQUIRED_CN_MESSAGE, + MODEL_PROVIDER_RUNTIMES_CN_MESSAGE, +} from "../../../core/project/manager"; +import { RegionUnsupportedFeatureError } from "../../../errors"; +import { BMA_TEMPLATE_NAME } from "../bmaProfile"; +import type { CreateProjectInput } from "../types"; + +/** Applies the hard create-time restrictions shared by the CLI and interactive wizard. */ +export function validateCreateRegionSupport(input: CreateProjectInput, region: string): void { + if (!isChinaRegion(region)) return; + + if (input.scaffoldHarnessInput !== undefined) { + throw new RegionUnsupportedFeatureError(HARNESS_CN_MESSAGE); + } + + const runtime = input.scaffoldRuntimeInput; + if (runtime === undefined || runtime.framework === "none") return; + if (runtime.framework === BMA_TEMPLATE_NAME) { + throw new RegionUnsupportedFeatureError(BMA_CN_MESSAGE); + } + if ((runtime.modelProvider ?? "Bedrock") !== "LiteLLM") { + throw new RegionUnsupportedFeatureError(MODEL_PROVIDER_RUNTIMES_CN_MESSAGE); + } + if (runtime.modelId === undefined) { + throw new RegionUnsupportedFeatureError(LITELLM_MODEL_ID_REQUIRED_CN_MESSAGE); + } + if (runtime.modelId.startsWith("bedrock/")) { + throw new RegionUnsupportedFeatureError(LITELLM_BEDROCK_MODEL_ID_CN_MESSAGE); + } +} diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index 582f8dffbd..8330cfd24d 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router"; import { ProjectNameSchema } from "../../../projectSchemas/project"; import type { ScreenProps } from "../../types"; import { PlatformKey } from "../../../router"; +import { RegionKey } from "../../keys"; import { assertProjectPathFits } from "./pathLimit"; import type { CreateProjectInput } from "../types"; import { @@ -31,6 +32,7 @@ import { } from "../../../components/wizard"; import { darkTheme } from "../../../components/ui/_core.js"; import { TuiExitMessageKey } from "../../../tui/exitMessage"; +import { validateCreateRegionSupport } from "./region"; const theme = darkTheme; @@ -155,7 +157,9 @@ export function ProjectCreateScreen({ ctx, core }: ScreenProps) { // them the way it reports a failed create — with the retry still on // offer, because nothing has to be cleaned up first. assertProjectPathFits(values.name, ctx.require(PlatformKey)); - return core.projectManager.create(buildCreateInput(values)); + const input = buildCreateInput(values); + validateCreateRegionSupport(input, ctx.require(RegionKey)); + return core.projectManager.create(input); }} runningLabel={`creating ${values.name}…`} successLabel={`project created in ./${values.name}`} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 907fbbdc41..929d178cbf 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1197,6 +1197,22 @@ describe("create in China regions", () => { ).rejects.toThrow(/not accessible from China regions/); }); + test("rejects the Bedrock Managed Agents template", async () => { + cleanups.push((await inTempDirectory()).cleanup); + await expect( + run([ + "create", + "--name", + "CnBma", + "--template", + "bedrock-managed-agents", + ...skips, + "--region", + "cn-north-1", + ]), + ).rejects.toThrow(/Bedrock Managed Agents is not available in China regions/); + }); + test("requires --model-id with litellm", async () => { cleanups.push((await inTempDirectory()).cleanup); await expect( From ae0fdc170b121dbb66c165e26d4ef14904dc0d9d Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 15:08:41 -0400 Subject: [PATCH 6/9] fix(project): add Docker ignore rules to BMA template --- .../dockerignore.template | 27 +++++++++++++++++++ .../project/add/runtime/index.test.ts | 2 +- src/handlers/project/project.test.ts | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/assets/templates/bedrock-managed-agents/dockerignore.template diff --git a/src/assets/templates/bedrock-managed-agents/dockerignore.template b/src/assets/templates/bedrock-managed-agents/dockerignore.template new file mode 100644 index 0000000000..a0c4eb6584 --- /dev/null +++ b/src/assets/templates/bedrock-managed-agents/dockerignore.template @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Secrets and environment files +.env +.env.* + +# Version control +.git/ + +# AgentCore build artifacts +.agentcore/artifacts/ +*.zip diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 2b00742ceb..fdfce409a9 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -298,7 +298,7 @@ describe("project add runtime", () => { expect(runtime.runtimeVersion).toBe(isContainer ? undefined : "PYTHON_3_14"); expect(await Bun.file(join(projectRoot, "app", name, "Dockerfile")).exists()).toBe(isContainer); expect(await Bun.file(join(projectRoot, "app", name, ".dockerignore")).exists()).toBe( - isContainer && !isBma, + isContainer, ); }); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 929d178cbf..99ea931654 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -384,6 +384,7 @@ describe("project create", () => { expect(spec.memories ?? []).toEqual([]); expect(await Bun.file(join(runtimeRoot, "lifecycle", "server.py")).exists()).toBe(true); expect(await Bun.file(join(runtimeRoot, "client.py")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, ".dockerignore")).exists()).toBe(true); expect(await Bun.file(join(runtimeRoot, "Dockerfile")).text()).toContain( "RUN uv sync --no-dev", ); From f66b86fb6e5bea85cff93ca65e1bf97002ee30d4 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 15:11:22 -0400 Subject: [PATCH 7/9] test(project): cover BMA asset lifecycle with test case --- .../templates/bedrockManagedAgents.test.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/core/project/templates/bedrockManagedAgents.test.ts diff --git a/src/core/project/templates/bedrockManagedAgents.test.ts b/src/core/project/templates/bedrockManagedAgents.test.ts new file mode 100644 index 0000000000..3f044fea89 --- /dev/null +++ b/src/core/project/templates/bedrockManagedAgents.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from "bun:test"; +import { parse } from "yaml"; +import { FsAssetSource } from "../source"; + +const BMA_ASSET_DIR = "templates/bedrock-managed-agents"; + +test("Bedrock Managed Agents assets preserve the environment and lifecycle contract", async () => { + const source = new FsAssetSource(); + const paths = await source.list(BMA_ASSET_DIR); + const read = (path: string) => source.read(`${BMA_ASSET_DIR}/${path}`); + const [ + dockerfile, + dockerignore, + policyText, + pyproject, + server, + collectorText, + client, + pluginText, + skill, + ] = await Promise.all([ + read("Dockerfile"), + read("dockerignore.template"), + read("bma-acr-policy.json"), + read("pyproject.toml"), + read("lifecycle/server.py"), + read("otel/collector.yaml"), + read("client.py"), + read("plugins/acr-report/.codex-plugin/plugin.json"), + read("plugins/acr-report/skills/acr-report/SKILL.md"), + ]); + + expect(paths).toEqual( + expect.arrayContaining([ + `${BMA_ASSET_DIR}/Dockerfile`, + `${BMA_ASSET_DIR}/dockerignore.template`, + `${BMA_ASSET_DIR}/bma-acr-policy.json`, + `${BMA_ASSET_DIR}/client.py`, + `${BMA_ASSET_DIR}/lifecycle/server.py`, + `${BMA_ASSET_DIR}/otel/collector.yaml`, + `${BMA_ASSET_DIR}/plugins/acr-report/.codex-plugin/plugin.json`, + `${BMA_ASSET_DIR}/plugins/acr-report/skills/acr-report/SKILL.md`, + `${BMA_ASSET_DIR}/pyproject.toml`, + ]), + ); + + // The image owns dependency installation and packages every lifecycle dependency. + expect(dockerfile).toContain("FROM public.ecr.aws/lambda/microvms:al2023-minimal"); + expect(dockerfile).toContain("https://chatgpt.com/codex/install.sh"); + expect(dockerfile).toContain("amazon-cloudwatch-agent.rpm"); + expect(dockerfile).toContain("COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/"); + expect(dockerfile).toContain("RUN uv sync --no-dev"); + expect(dockerfile).toContain("COPY lifecycle/ lifecycle/"); + expect(dockerfile).toContain("COPY otel/ otel/"); + expect(dockerfile).toContain("COPY plugins/ plugins/"); + expect(dockerfile).toContain("USER app"); + expect(dockerfile).toContain( + 'CMD ["uv", "run", "--no-sync", "opentelemetry-instrument", "python", "-u", "lifecycle/server.py"]', + ); + expect(dockerignore).toContain(".venv/"); + expect(dockerignore).toContain(".env"); + expect(dockerignore).toContain(".git/"); + expect(dockerignore).toContain(".agentcore/artifacts/"); + + const policy = JSON.parse(policyText); + expect(policy).toEqual({ + Version: "2012-10-17", + Statement: [ + { + Sid: "AttachToBmaEnvironment", + Effect: "Allow", + Action: ["bedrock-mantle:RegisterEnvironment", "bedrock-mantle:ConnectEnvironment"], + Resource: "arn:*:bedrock-mantle:*:*:project/*", + }, + ], + }); + + const version = /^version = "([^"]+)"$/m.exec(pyproject)?.[1]; + expect(version).toBeDefined(); + expect(pyproject).toContain('"aws-opentelemetry-distro",'); + expect(pyproject).toContain('"bedrock-agentcore",'); + expect(pyproject).toContain('"aws-bedrock-token-generator>=1.1.0",'); + expect(pyproject).toContain('"openai>=3.16.2",'); + expect(pyproject).toContain("[tool.uv]\npackage = false"); + + // BMA calls this protocol, and the server translates its failures into HTTP 200 bodies. + expect(server).toContain(`BMA_VERSION = "${version}"`); + expect(server).toContain('BMA_REMOTE_SERVICE = "bedrock-mantle"'); + expect(server).toContain("PROTOCOL_VERSION = 1"); + expect(server).toContain('"activate": self.activate'); + expect(server).toContain('"renew_turn_lease": self.renew_turn_lease'); + expect(server).toContain('"release_turn_lease": self.release_turn_lease'); + expect(server).toContain('"disconnect": lambda _: self.disconnect()'); + expect(server).toContain('if self.path == "/ping":'); + expect(server).toContain('if self.path != "/invocations":'); + expect(server).toContain("self.send_response(HTTPStatus.OK)"); + expect(server).toContain('"status_code": int(status)'); + expect(server).toContain('"workspace_directory": directory'); + expect(server).toContain('workspace = Path(attachment["workspace_directory"])'); + expect(server).toContain("cwd=workspace"); + expect(server).toContain('"attachment_generation": generation'); + expect(server).toContain("accepted = min(duration, BMA_MAX_TURN_LEASE)"); + expect(server).toContain('temporary = directory / "state.json.tmp"'); + expect(server).toContain('temporary.replace(directory / "state.json")'); + expect(server).toContain( + 'runtime_session_id = self.headers.get(\n "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"', + ); + expect(server).toContain("threading.Thread(target=LIFECYCLE.monitor, daemon=True).start()"); + expect(server).toContain('ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()'); + + // The exec-server exports locally; the collector adds the Runtime session and signs AWS exports. + expect(server).toContain('COLLECTOR_ENDPOINT = "http://127.0.0.1:4318"'); + expect(server).toContain("*(TELEMETRY_OVERRIDES if self._ensure_collector_locked() else [])"); + expect(server).toContain('"AGENTCORE_RUNTIME_SID": self.runtime_session_id'); + const collector = parse(collectorText); + expect(collector).toMatchObject({ + receivers: { + otlp: { + protocols: { + http: { endpoint: "127.0.0.1:4318", include_metadata: true }, + }, + }, + }, + processors: { + attributes: { + actions: [{ key: "session.id", value: "${env:AGENTCORE_RUNTIME_SID}", action: "upsert" }], + }, + }, + extensions: { + "sigv4auth/xray": { region: "${env:AWS_REGION}", service: "xray" }, + "sigv4auth/logs": { region: "${env:AWS_REGION}", service: "logs" }, + "headers_setter/traces": { additional_auth: "sigv4auth/xray" }, + "headers_setter/logs": { additional_auth: "sigv4auth/logs" }, + }, + service: { + pipelines: { + traces: { receivers: ["otlp"], processors: ["attributes"], exporters: ["otlphttp/traces"] }, + logs: { receivers: ["otlp"], processors: ["attributes"], exporters: ["otlphttp/logs"] }, + }, + }, + }); + expect(collectorText).toContain("from_context: x-aws-log-group"); + expect(collectorText).toContain("from_context: x-aws-log-stream"); + + // The sample client must address Mantle in the Runtime region and request the ACR environment. + expect(client).toContain('region = args.runtime.split(":")[3]'); + expect(client).toContain("api_key=lambda: provide_token(region=region)"); + expect(client).toContain('base_url=f"https://bedrock-mantle.{region}.api.aws/openai/v1"'); + expect(client).toContain('WORKSPACE_DIRECTORY = "/home/app/workspace"'); + expect(client).toContain('CAPABILITY_DIRECTORIES = ["/opt/bma/plugins"]'); + expect(client).toContain('"type": "aws_bedrock_agentcore"'); + expect(client).toContain('"runtime_qualifier": "DEFAULT"'); + expect(client).toContain('"workspace_directory": WORKSPACE_DIRECTORY'); + expect(client).toContain('"capability_directories": CAPABILITY_DIRECTORIES'); + expect(client).toContain("stream=True"); + expect(client).toContain('extra_query={"stream": "true"}'); + + expect(JSON.parse(pluginText)).toEqual({ + name: "acr-report", + version: "0.1.0", + description: "Report the Python version, user, and working directory of the ACR.", + skills: "./skills/", + }); + expect(skill).toContain("acr-report.txt"); + expect(skill).toContain("Python version"); + expect(skill).toContain("working directory"); +}); From 5dc60bee506e2538a0e02d666fe8b05e0b9de23b Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 15:23:34 -0400 Subject: [PATCH 8/9] fix(project): derive BMA profile from framework --- src/core/project/manager.test.ts | 4 ++- src/core/project/manager.tsx | 9 ++--- src/core/project/templates/runtime.ts | 3 +- .../project/runtimeTemplateProfile.test.ts | 34 +++++++++++++++++++ .../project/runtimeTemplateProfile.ts | 18 ++++++++++ 5 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/handlers/project/runtimeTemplateProfile.test.ts create mode 100644 src/handlers/project/runtimeTemplateProfile.ts diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 3904b12604..fd7f490115 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -293,9 +293,11 @@ describe("FsProjectManager.create", () => { test("scaffolds the Bedrock Managed Agents environment and runtime defaults", async () => { const directory = await inTempDirectory(); const setup = manager(); + const bmaWithoutTemplateProfile: ScaffoldRuntimeInput = { ...BEDROCK_MANAGED_AGENTS }; + delete bmaWithoutTemplateProfile.templateProfile; await runCreate(setup.manager, { name: "example", - scaffoldRuntimeInput: BEDROCK_MANAGED_AGENTS, + scaffoldRuntimeInput: bmaWithoutTemplateProfile, }); const projectRoot = join(directory, "example"); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index dba6eeeb02..7543f6e9eb 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -93,6 +93,7 @@ import { HandlebarsTemplateRenderer } from "./templates/renderer"; import type { CreateCloudFormationClient } from "../types"; import type { CoreIdentityClient } from "../../handlers/identity/types"; import { templateManagesDependencies } from "../../handlers/project/templateProfile"; +import { resolveRuntimeTemplateProfile } from "../../handlers/project/runtimeTemplateProfile"; import { BMA_POLICY_FILE, BMA_TEMPLATE_NAME, @@ -357,7 +358,7 @@ export class FsProjectManager implements ProjectManager { } } else if ( scaffoldRuntimeInput?.build === "Container" && - templateManagesDependencies(scaffoldRuntimeInput.templateProfile) + templateManagesDependencies(resolveRuntimeTemplateProfile(scaffoldRuntimeInput)) ) { // Container builds install from a lockfile, so generate it even with no-install. const appDir = join(destination, "app", scaffoldRuntimeInput.runtimeName); @@ -1403,7 +1404,7 @@ export class FsProjectManager implements ProjectManager { await this.checkTool("npm", NODE_INSTALL_HINT); if ( input.scaffoldRuntimeInput?.language === "Python" && - templateManagesDependencies(input.scaffoldRuntimeInput.templateProfile) + templateManagesDependencies(resolveRuntimeTemplateProfile(input.scaffoldRuntimeInput)) ) { await this.checkTool("uv", UV_INSTALL_HINT); } @@ -1416,7 +1417,7 @@ export class FsProjectManager implements ProjectManager { private async checkRuntimeDependency( input: RuntimeResourceConfig["scaffoldRuntimeInput"], ): Promise { - if (!templateManagesDependencies(input.templateProfile)) return; + if (!templateManagesDependencies(resolveRuntimeTemplateProfile(input))) return; if (input.language === "Python") { await this.checkTool("uv", UV_INSTALL_HINT); } else { @@ -1432,7 +1433,7 @@ export class FsProjectManager implements ProjectManager { appDir: string, input?: RuntimeResourceConfig["scaffoldRuntimeInput"], ): AsyncGenerator { - if (input && !templateManagesDependencies(input.templateProfile)) return; + if (input && !templateManagesDependencies(resolveRuntimeTemplateProfile(input))) return; if (existsSync(join(appDir, "pyproject.toml"))) { await this.checkTool("uv", UV_INSTALL_HINT); yield { type: "step", message: "Syncing Python dependencies with uv" }; diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index ec3cc5c530..ea2004fce4 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -14,6 +14,7 @@ import { defaultMemoryName, memoryEnvVarName } from "../../../projectSchemas/mem import { InputValidationError } from "../../../errors"; import { toPythonPackageName } from "../fsUtils"; import { templateUsesModel } from "../../../handlers/project/templateProfile"; +import { resolveRuntimeTemplateProfile } from "../../../handlers/project/runtimeTemplateProfile"; /** A model provider's render context, spec entries, and .env.local secrets for a scaffolded runtime. */ type ModelProviderTemplateConfig = { @@ -53,7 +54,7 @@ function resolveModelProviderScaffold(input: RuntimeResourceConfig): ModelProvid function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { const { scaffoldRuntimeInput, name, ...infra } = input; - const profile = scaffoldRuntimeInput.templateProfile; + const profile = resolveRuntimeTemplateProfile(scaffoldRuntimeInput); const runtimeProfile = profile?.runtime; const usesModel = templateUsesModel(profile); const lifecycleConfiguration = mergeLifecycleConfiguration( diff --git a/src/handlers/project/runtimeTemplateProfile.test.ts b/src/handlers/project/runtimeTemplateProfile.test.ts new file mode 100644 index 0000000000..579423382e --- /dev/null +++ b/src/handlers/project/runtimeTemplateProfile.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { BMA_TEMPLATE_PROFILE } from "./bmaProfile"; +import { resolveRuntimeTemplateProfile } from "./runtimeTemplateProfile"; + +describe("resolveRuntimeTemplateProfile", () => { + test("derives the canonical BMA profile from the framework", () => { + expect( + resolveRuntimeTemplateProfile({ + framework: "bedrock-managed-agents", + }), + ).toBe(BMA_TEMPLATE_PROFILE); + + expect( + resolveRuntimeTemplateProfile({ + framework: "bedrock-managed-agents", + templateProfile: { + usesModel: true, + dependencySetup: "managed", + runtime: { entrypoint: "main.py" }, + }, + }), + ).toBe(BMA_TEMPLATE_PROFILE); + }); + + test("preserves caller-supplied profiles for other frameworks", () => { + const profile = { usesModel: false } as const; + expect( + resolveRuntimeTemplateProfile({ + framework: "none", + templateProfile: profile, + }), + ).toBe(profile); + }); +}); diff --git a/src/handlers/project/runtimeTemplateProfile.ts b/src/handlers/project/runtimeTemplateProfile.ts new file mode 100644 index 0000000000..de23fe4001 --- /dev/null +++ b/src/handlers/project/runtimeTemplateProfile.ts @@ -0,0 +1,18 @@ +import { BMA_TEMPLATE_NAME, BMA_TEMPLATE_PROFILE } from "./bmaProfile"; +import type { RuntimeTemplateProfile } from "./templateProfile"; +import type { ScaffoldRuntimeInput } from "./types"; + +/** + * Resolve the effective profile from stable scaffold fields. + * + * BMA's runtime contract is intrinsic to its framework, so reconstructed + * inputs cannot lose or override it by omitting or changing internal metadata. + */ +export function resolveRuntimeTemplateProfile( + input: Pick, +): RuntimeTemplateProfile | undefined { + if (input.framework === BMA_TEMPLATE_NAME) { + return BMA_TEMPLATE_PROFILE; + } + return input.templateProfile; +} From cc674fa338b41ee0941abd281e940a1320144a99 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 30 Sep 2026 15:48:05 -0400 Subject: [PATCH 9/9] chore(project): update documentation language --- README.md | 12 ------------ src/handlers/project/shortcuts.ts | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/README.md b/README.md index 1509acda6a..f3068675bb 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,6 @@ Run this alternative from outside an existing project: agentcore create --name MyAgent --template agent-python-strands ``` -To create an execution environment for Bedrock Managed Agents: - -```bash -agentcore create --name MyManagedAgent --template bedrock-managed-agents -``` - -Bedrock Managed Agents runs the agent loop and sends lifecycle and command requests to the -generated AgentCore Runtime. The Runtime is a container environment for Codex rather than an agent -that invokes a model itself, so it has no model-provider or memory configuration. Its dependencies -are installed when the container image is built. The generated Runtime README covers deployment, -the sample client, persistence, observability, and adding skills. - ## Command Surface Project commands manage local project specifications and their deployments. diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts index b6a1fb64db..06e942011b 100644 --- a/src/handlers/project/shortcuts.ts +++ b/src/handlers/project/shortcuts.ts @@ -119,7 +119,7 @@ export const RUNTIME_TEMPLATE_SHORTCUTS = { }, "bedrock-managed-agents": { runtimeName: "bedrock_managed_agents", - description: "Codex execution environment for Bedrock Managed Agents", + description: "Execution environment for Bedrock Managed Agents", build: "Container", language: "Python", framework: "bedrock-managed-agents",