Skip to content
Merged
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
41 changes: 41 additions & 0 deletions src/assets/templates/bedrock-managed-agents/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
134 changes: 134 additions & 0 deletions src/assets/templates/bedrock-managed-agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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:<region>:<account-id>:project/<project-id>`. |
| `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.

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`.

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
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 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`.

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 <ACR ARN>
```

`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 <ACR ARN> --session-id <BMA session ID> --input "List the files in the workspace."
```

If you add a Gateway, add `--gateway <Gateway MCP URL>` 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`.
11 changes: 11 additions & 0 deletions src/assets/templates/bedrock-managed-agents/bma-acr-policy.json
Original file line number Diff line number Diff line change
@@ -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/*"
}
]
}
132 changes: 132 additions & 0 deletions src/assets/templates/bedrock-managed-agents/client.py
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 27 additions & 0 deletions src/assets/templates/bedrock-managed-agents/dockerignore.template
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading