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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/assets/templates/bedrock-managed-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ image build downloads Codex and Python from the internet, so a build in VPC mode

`agentcore create` writes these settings to `agentcore/agentcore.json`:

- `bedrockManagedAgents: true` on the Runtime, which tells CDK to create a BMA session role for this project stack.
- 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
Expand Down Expand Up @@ -110,6 +111,23 @@ agentcore deploy

Give the ACR ARN to BMA when you create the BMA environment.

# Session role

CDK creates two roles for this environment. The ACR execution role gets `bma-acr-policy.json` so it can connect to BMA.
BMA assumes the separate session role to invoke the model, this project's BMA Runtimes, and Gateways in the account
and Region. The session role is created once per project stack, shared by its BMA Runtimes, and deleted with that
stack. The CLI records its ARN and the BMA Runtime ARNs in `agentcore/.cli/deployed-state.json` after a successful
deploy.

The generated `client.py` looks up the session role for the `--runtime` ARN in that file and sends it when creating a
session. If the Runtime is not in the deployed state, run `agentcore deploy` again. You can instead give
`--role-arn <ARN>` to use a role you manage yourself. That role must trust `bedrock-mantle.amazonaws.com` and have the
session role permissions shown by the CDK stack.

The identity running the client needs `iam:PassRole` on the session role, with `iam:PassedToService` set to
`bedrock-mantle.amazonaws.com`. It does not need IAM role creation or policy update permissions. Existing sessions
continue to use the role selected when they were created.

# Run the client

Run the client from this directory with the ACR ARN from `agentcore status`:
Expand Down
34 changes: 34 additions & 0 deletions src/assets/templates/bedrock-managed-agents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import json
from pathlib import Path
from typing import Any

from aws_bedrock_token_generator import provide_token
Expand All @@ -14,6 +15,28 @@
TOOL_CALLS = ("mcp_call", "function_call", "web_search_call")


def deployed_session_role(runtime_arn: str) -> str:
"""Find the CDK-managed session role deployed alongside this Runtime."""
project_root = Path(__file__).resolve().parents[2]
state_path = project_root / "agentcore" / ".cli" / "deployed-state.json"
try:
state = json.loads(state_path.read_text())
except (OSError, ValueError) as error:
raise ValueError(f"Cannot read {state_path}: {error}") from error

matches = []
for target in state.get("targets", {}).values():
session = target.get("resources", {}).get("bmaSession") or {}
if runtime_arn in session.get("runtimeArns", []):
matches.append(session["roleArn"])
if len(matches) != 1:
raise ValueError(
f"Expected one deployed BMA session role for {runtime_arn}, found {len(matches)}. "
"Run agentcore deploy, or give --role-arn."
)
return matches[0]


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.")
Expand Down Expand Up @@ -46,6 +69,10 @@ def main() -> None:
"--gateway",
help="The Gateway URL from the output of `agentcore deploy`.",
)
parser.add_argument(
"--role-arn",
help="Use this BMA session role instead of the CDK-managed role for the Runtime.",
)
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()
Expand All @@ -68,6 +95,12 @@ def main() -> None:
if session["environment"].get("runtime_arn") != args.runtime:
raise ValueError(f"Session {session_id} uses another ACR.")

if not session_id and not args.role_arn:
try:
args.role_arn = deployed_session_role(args.runtime)
except ValueError as error:
parser.error(str(error))

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"})
Expand Down Expand Up @@ -110,6 +143,7 @@ def main() -> None:
},
input=args.input,
stream=True,
extra_body={"role_arn": args.role_arn},
)

with events:
Expand Down
71 changes: 71 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Stack } from "@aws-sdk/client-cloudformation";
import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types";
import { FsReadWriteJson, ProcessFailedError } from "../../../io";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { ProjectRuntimeSchema } from "../../../projectSchemas/runtime";
import { createSilentLogger } from "../../../testing";
import { TransactionSearchSetupError } from "../../../errors";
import { CdkBackend } from "./cdk";
Expand Down Expand Up @@ -513,6 +514,76 @@ describe("CdkBackend.deploy", () => {
});
});

test("records the CDK-managed BMA role with its runtime ARN for the sample client", async () => {
const input = await project();
input.spec.runtimes.push(
ProjectRuntimeSchema.parse({
name: "bma",
build: "Container",
codeLocation: "app/bma",
entrypoint: "lifecycle/server.py",
bedrockManagedAgents: true,
}),
);
await writeAssembly(input, [TARGET.name]);
const roleArn = "arn:aws:iam::111122223333:role/ProjectBmaSessionRole";
const runtimeArn = "arn:aws:bedrock-agentcore:us-east-1:111122223333:runtime/bma123";
const subject = harness({
describedStack: {
StackName: "AgentCore-example-default",
CreationTime: new Date(0),
StackStatus: "CREATE_COMPLETE",
Outputs: [
{ ExportName: "AgentCore-example-default-BmaSessionRoleArn", OutputValue: roleArn },
{ ExportName: "AgentCore-example-default-bma-RuntimeArn", OutputValue: runtimeArn },
],
},
});

await collectDeploy(subject.backend.deploy(input, deployInput()));

const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
const state = JSON.parse(await Bun.file(statePath).text());
expect(state.targets.default.resources.bmaSession).toEqual({
roleArn,
runtimeArns: [runtimeArn],
});

input.spec.runtimes[0]!.bedrockManagedAgents = false;
await collectDeploy(subject.backend.deploy(input, deployInput()));
const afterRemoval = JSON.parse(await Bun.file(statePath).text());
expect(afterRemoval.targets.default.resources.bmaSession).toBeUndefined();

input.spec.runtimes[0]!.bedrockManagedAgents = undefined;
input.spec.runtimes[0]!.tags = { "agentcore:template": "BedrockManagedAgents" };
input.spec.runtimes[0]!.additionalPolicies = ["bma-acr-policy.json"];
await collectDeploy(subject.backend.deploy(input, deployInput()));
const afterMarkersOnly = JSON.parse(await Bun.file(statePath).text());
expect(afterMarkersOnly.targets.default.resources.bmaSession).toBeUndefined();
});

test("reports a missing BMA role output from an older CDK dependency", async () => {
const input = await project();
input.spec.runtimes.push(
ProjectRuntimeSchema.parse({
name: "bma",
build: "Container",
codeLocation: "app/bma",
entrypoint: "lifecycle/server.py",
bedrockManagedAgents: true,
}),
);
await writeAssembly(input, [TARGET.name]);
const subject = harness();

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/Update @aws\/agentcore-cdk/,
);
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
const state = JSON.parse(await Bun.file(statePath).text());
expect(state.targets.default.resources.bmaSession).toBeUndefined();
});

test("provisions credentials for the target before synth and records them under it", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
Expand Down
41 changes: 37 additions & 4 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ function cdkId(name: string): string {
return name.replace(/_/g, "");
}

function stackExportName(stackName: string, ...parts: string[]): string {
return [stackName, ...parts]
.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, ""))
.join("-");
}

function findDeployedResourceId(
stack: Stack,
input: Pick<ResolvedDeployedResource, "resourceType" | "name">,
Expand Down Expand Up @@ -365,7 +371,36 @@ export class CdkBackend implements ProjectBackend {
// Persist the deployed stack's ARN so later commands read live resource state
// from CloudFormation. Merged per target, so deploying one target never drops
// another's recorded state.
await updateTargetState(this.json, project.rootPath, target.name, { stackArn });
await updateTargetState(this.json, project.rootPath, target.name, {
stackArn,
resources: { bmaSession: undefined },
});

const bmaRuntimes = project.spec.runtimes.filter(
(runtime) => runtime.bedrockManagedAgents === true,
);
if (bmaRuntimes.length > 0) {
const stack = await this.describeStack(target.region, credentials, stackArn);
const output = (...parts: string[]) =>
stack?.Outputs?.find(
(entry) => entry.ExportName === stackExportName(stack.StackName ?? "", ...parts),
)?.OutputValue;
const roleArn = output("BmaSessionRoleArn");
const runtimeArns: string[] = [];
for (const runtime of bmaRuntimes) {
const arn = output(runtime.name, "RuntimeArn");
if (arn) runtimeArns.push(arn);
}
if (!roleArn || runtimeArns.length !== bmaRuntimes.length) {
throw new MalformedServiceResponseError(
`The deployed stack '${stack?.StackName ?? artifact.stackName}' is missing BMA session role or runtime ARN outputs. ` +
"Update @aws/agentcore-cdk in agentcore/cdk/package.json to a version with BMA session role support and deploy again.",
);
}
await updateTargetState(this.json, project.rootPath, target.name, {
resources: { bmaSession: { roleArn, runtimeArns } },
});
}

// After the stack update, since a resource in it may have been using the provider
// until this deploy removed the reference.
Expand Down Expand Up @@ -542,9 +577,7 @@ export class CdkBackend implements ProjectBackend {
if (!stack?.StackName) return undefined;
// The CDK library builds every ExportName through this shared helper
// https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/logical-ids.ts#L84
const want = [stack.StackName, ...parts]
.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, ""))
.join("-");
const want = stackExportName(stack.StackName, ...parts);
return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue;
};

Expand Down
7 changes: 7 additions & 0 deletions src/core/project/backends/cdk/deployedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ const CredentialStateSchema = z
const ResourceStateSchema = z
.object({
credentials: z.record(z.string(), CredentialStateSchema).optional(),
/** CDK-managed BMA session role and the runtimes this stack deployed it for. */
bmaSession: z
.object({
roleArn: z.string(),
runtimeArns: z.array(z.string()),
})
.optional(),
// The legacy deployer recorded the CloudFormation stack name
// here. New deploys record the stack ARN instead. Keep this
// field so projects can be correctly inspected after upgrading.
Expand Down
36 changes: 35 additions & 1 deletion src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ describe("FsProjectManager.create", () => {
{
name: "bedrock_managed_agents",
build: "Container",
bedrockManagedAgents: true,
entrypoint: "lifecycle/server.py",
codeLocation: "app/bedrock_managed_agents",
dockerfile: "Dockerfile",
Expand Down Expand Up @@ -896,7 +897,14 @@ describe("FsProjectManager.addResource", () => {
async function editSpec(
project: Project,
edit: (spec: {
runtimes: { name: string; modelProvider?: string; modelId?: string }[];
runtimes: {
name: string;
modelProvider?: string;
modelId?: string;
bedrockManagedAgents?: boolean;
tags?: Record<string, string>;
additionalPolicies?: string[];
}[];
harnesses: unknown[];
memories?: unknown[];
}) => void,
Expand Down Expand Up @@ -955,6 +963,32 @@ describe("FsProjectManager.addResource", () => {
expect(deployCalls).toEqual([]);
});

test("deploy to a China target recognizes the explicit BMA field", async () => {
await inTempDirectory();
const { subject, project, deployCalls } = await projectWithTarget("cn-north-1");
await editSpec(project, (spec) => {
spec.runtimes[0]!.bedrockManagedAgents = true;
});

const { error } = await deployOutcome(subject, project);
expect(error).toBeInstanceOf(RegionUnsupportedFeatureError);
expect(String(error)).toContain(BMA_CN_MESSAGE);
expect(deployCalls).toEqual([]);
});

test("deploy to a China target ignores BMA template markers without the explicit field", async () => {
await inTempDirectory();
const { subject, project, deployCalls } = await projectWithTarget("cn-north-1");
await editSpec(project, (spec) => {
spec.runtimes[0]!.tags = { "agentcore:template": "BedrockManagedAgents" };
spec.runtimes[0]!.additionalPolicies = ["bma-acr-policy.json"];
});

const { error } = await deployOutcome(subject, project);
expect(error).toBeUndefined();
expect(deployCalls).toHaveLength(1);
});

test("deploy to a China target proceeds past the gate for LiteLLM", async () => {
await inTempDirectory();
const { subject, project, deployCalls } = await projectWithTarget("cn-north-1");
Expand Down
11 changes: 2 additions & 9 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,7 @@ 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,
BMA_TEMPLATE_TAG_KEY,
BMA_TEMPLATE_TAG_VALUE,
} from "../../handlers/project/bmaProfile";
import { BMA_TEMPLATE_NAME } from "../../handlers/project/bmaProfile";

const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]';

Expand Down Expand Up @@ -1168,9 +1163,7 @@ export class FsProjectManager implements ProjectManager {
// 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),
(runtime) => runtime.bedrockManagedAgents === true,
);
if (bmaRuntimes.length > 0) {
throw new RegionUnsupportedFeatureError(
Expand Down
4 changes: 4 additions & 0 deletions src/core/project/templates/bedrockManagedAgents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ test("Bedrock Managed Agents assets preserve the environment and lifecycle contr
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("deployed-state.json");
expect(client).toContain('"bmaSession"');
expect(client).toContain('extra_body={"role_arn": args.role_arn}');
expect(client).not.toContain("boto3");
expect(client).toContain('"type": "aws_bedrock_agentcore"');
expect(client).toContain('"runtime_qualifier": "DEFAULT"');
expect(client).toContain('"workspace_directory": WORKSPACE_DIRECTORY');
Expand Down
3 changes: 3 additions & 0 deletions src/core/project/templates/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime {
...(infra.description && { description: infra.description }),
...(infra.executionRoleArn && { executionRoleArn: infra.executionRoleArn }),
...(additionalPolicies.length > 0 && { additionalPolicies }),
...(scaffoldRuntimeInput.framework === "bedrock-managed-agents" && {
bedrockManagedAgents: true,
}),
...(infra.envVars && { envVars: infra.envVars }),
...(infra.networkMode && { networkMode: infra.networkMode }),
...(infra.networkConfig && { networkConfig: infra.networkConfig }),
Expand Down
3 changes: 3 additions & 0 deletions src/handlers/project/add/runtime/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe("project add runtime", () => {
},
"bedrock-managed-agents template preset": {
build: "Container",
bedrockManagedAgents: true,
entrypoint: "lifecycle/server.py",
dockerfile: "Dockerfile",
protocol: "HTTP",
Expand Down Expand Up @@ -342,6 +343,7 @@ describe("project add runtime", () => {
expect(spec.runtimes).toContainEqual(
expect.objectContaining({
name: "my_bma",
bedrockManagedAgents: true,
lifecycleConfiguration: {
idleRuntimeSessionTimeout: 300,
maxLifetime: 3600,
Expand Down Expand Up @@ -377,6 +379,7 @@ describe("project add runtime", () => {
expect(spec.runtimes).toContainEqual(
expect.objectContaining({
name: "my_bma",
bedrockManagedAgents: true,
executionRoleArn: roleArn,
additionalPolicies: ["bma-acr-policy.json"],
}),
Expand Down
1 change: 1 addition & 0 deletions src/handlers/project/add/runtime/runtime.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ describe("project add runtime wizard", () => {

expect(await runtimeInSpec(projectRoot, "bma_environment")).toMatchObject({
build: "Container",
bedrockManagedAgents: true,
entrypoint: "lifecycle/server.py",
additionalPolicies: ["bma-acr-policy.json"],
});
Expand Down
Loading
Loading