@aws/agentcore 1.0.0-preview.31 → 1.0.0-preview.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -711,7 +711,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m
711
711
  "typescript": "~5.9.3"
712
712
  },
713
713
  "dependencies": {
714
- "@aws/agentcore-cdk": "0.1.0-alpha.53",
714
+ "@aws/agentcore-cdk": "0.1.0-alpha.54",
715
715
  "aws-cdk-lib": "~2.261.0",
716
716
  "constructs": "~10.7.0"
717
717
  }
@@ -872,6 +872,15 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f
872
872
  "python/http/autogen/base/model/__init__.py",
873
873
  "python/http/autogen/base/model/load.py",
874
874
  "python/http/autogen/base/pyproject.toml",
875
+ "python/http/bma/base/Dockerfile",
876
+ "python/http/bma/base/README.md",
877
+ "python/http/bma/base/bma-acr-policy.json",
878
+ "python/http/bma/base/client.py",
879
+ "python/http/bma/base/lifecycle/server.py",
880
+ "python/http/bma/base/otel/collector.yaml",
881
+ "python/http/bma/base/plugins/acr-report/.codex-plugin/plugin.json",
882
+ "python/http/bma/base/plugins/acr-report/skills/acr-report/SKILL.md",
883
+ "python/http/bma/base/pyproject.toml",
875
884
  "python/http/googleadk/base/README.md",
876
885
  "python/http/googleadk/base/gitignore.template",
877
886
  "python/http/googleadk/base/main.py",
@@ -3848,6 +3857,1099 @@ packages = ["."]
3848
3857
  "
3849
3858
  `;
3850
3859
 
3860
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/Dockerfile should match snapshot 1`] = `
3861
+ "FROM public.ecr.aws/lambda/microvms:al2023-minimal
3862
+
3863
+ # OS setup
3864
+ RUN dnf install -y tar gzip findutils shadow-utils ca-certificates \\
3865
+ && dnf clean all \\
3866
+ && useradd -m -u 1000 app
3867
+
3868
+ # Codex
3869
+ RUN curl -fsSL https://chatgpt.com/codex/install.sh -o /tmp/install-codex.sh \\
3870
+ && CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR=/opt/bma/bin CODEX_HOME=/opt/bma/codex \\
3871
+ sh /tmp/install-codex.sh \\
3872
+ && chmod -R a+rX /opt/bma \\
3873
+ && rm /tmp/install-codex.sh
3874
+
3875
+ # CloudWatch agent
3876
+ RUN arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \\
3877
+ && curl -fsSL -o /tmp/cwagent.rpm \\
3878
+ https://amazoncloudwatch-agent.s3.amazonaws.com/amazon_linux/\${arch}/latest/amazon-cloudwatch-agent.rpm \\
3879
+ && rpm -i /tmp/cwagent.rpm \\
3880
+ && rm /tmp/cwagent.rpm
3881
+
3882
+ # uv and Python
3883
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
3884
+ ARG UV_DEFAULT_INDEX
3885
+ ARG UV_INDEX
3886
+ ENV UV_PYTHON_INSTALL_DIR=/opt/python UV_PYTHON_BIN_DIR=/usr/local/bin
3887
+ RUN uv python install --default
3888
+
3889
+ # Application. The layout of /opt/bma is the same as the layout of this directory.
3890
+ WORKDIR /opt/bma
3891
+ ENV UV_COMPILE_BYTECODE=1 UV_NO_PROGRESS=1 \\
3892
+ UV_DEFAULT_INDEX=\${UV_DEFAULT_INDEX} UV_INDEX=\${UV_INDEX}
3893
+ COPY pyproject.toml uv.lock* ./
3894
+ RUN uv sync --no-dev
3895
+ COPY lifecycle/ lifecycle/
3896
+ COPY otel/ otel/
3897
+ COPY plugins/ plugins/
3898
+ USER app
3899
+
3900
+ EXPOSE 8080
3901
+ CMD ["uv", "run", "--no-sync", "opentelemetry-instrument", "python", "-u", "lifecycle/server.py"]
3902
+ "
3903
+ `;
3904
+
3905
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/README.md should match snapshot 1`] = `
3906
+ "This is a Bedrock Managed Agents (BMA) environment generated by the AgentCore CLI.
3907
+
3908
+ # Layout
3909
+
3910
+ BMA runs the agent loop (Codex) in the Bedrock Managed Agents service. This AgentCore Runtime (ACR) is the customer
3911
+ environment where BMA runs commands. The ACR has no model code.
3912
+
3913
+ | Path | Description |
3914
+ | --- | --- |
3915
+ | \`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. |
3916
+ | \`lifecycle/server.py\` | The environment lifecycle server, \`bma-acr-lifecycle\`. It handles the lifecycle calls from BMA and starts \`codex exec-server\`. |
3917
+ | \`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\`. |
3918
+ | \`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\`. |
3919
+ | \`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>\`. |
3920
+ | \`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. |
3921
+ | \`client.py\` | A sample OpenAI SDK client. It creates a session in BMA, and BMA sends the session's commands to this ACR. |
3922
+
3923
+ Do not change \`lifecycle/server.py\`. It must match the lifecycle calls that BMA makes.
3924
+
3925
+ The server always sends HTTP 200, because the Runtime changes any other status to 424 and drops the body. If a call
3926
+ fails, the body has the HTTP status code of the failure in \`status_code\` and the reason in \`error\`, for example
3927
+ \`{"error": "the exec-server did not start", "status_code": 503}\`.
3928
+
3929
+ # Codex version
3930
+
3931
+ The image build installs the latest Codex release. The server needs Codex 0.154.0 or newer because it runs
3932
+ \`codex exec-server\`. To pin a release, set \`CODEX_RELEASE\` next to \`CODEX_NON_INTERACTIVE\` in the \`Dockerfile\`. The
3933
+ image build downloads Codex and Python from the internet, so a build in VPC mode needs a route to the internet.
3934
+
3935
+ # ACR settings
3936
+
3937
+ \`agentcore create\` writes these settings to \`agentcore/agentcore.json\`:
3938
+
3939
+ - An idle timeout of 1800 seconds (30 minutes) and a maximum lifetime of 28800
3940
+ seconds (8 hours).
3941
+ - No session storage. Session storage is only for a microVM Runtime, so without it the same settings work on a
3942
+ capacity provider.
3943
+
3944
+ A value that you give to \`agentcore create\` replaces the value above.
3945
+
3946
+ The server keeps the connection state in \`state.json\` in \`BMA_STATE_DIR\`. The default is \`/home/app/.bma\`. The client
3947
+ sets the workspace in \`workspace_directory\` when it creates the session. \`client.py\` uses \`/home/app/workspace\`. The
3948
+ server creates that directory and runs the agent commands in it. If the activate call has no workspace directory, the
3949
+ server returns status 400. If the server cannot create the directory, it returns status 503, logs
3950
+ \`workspace_unavailable\`, and does not start the exec-server.
3951
+
3952
+ Files in the home directory do not stay after an idle stop. On a microVM Runtime, to keep the files and the connection
3953
+ state after an idle stop, put the home directory on session storage:
3954
+
3955
+ 1. Add session storage with \`--session-storage-mount-path /mnt/home\`.
3956
+ 2. Set \`BMA_HOME_DIR\` to \`/mnt/home\` in \`envVars\`. Then \`state.json\` is in \`/mnt/home/.bma\`, and \`CODEX_HOME\` is
3957
+ \`/mnt/home/.codex\`.
3958
+ 3. Set \`WORKSPACE_DIRECTORY\` in \`client.py\` to \`/mnt/home/workspace\`.
3959
+
3960
+ If you set \`WORKSPACE_DIRECTORY\` to a path on session storage, the Runtime must have session storage. If not, the
3961
+ server cannot create the workspace, and the session fails. If \`BMA_STATE_DIR\` is not on a mounted path, or the server
3962
+ cannot create it, the server keeps \`state.json\` in \`.bma\` in \`BMA_HOME_DIR\`.
3963
+
3964
+ # Environment variables
3965
+
3966
+ To change a setting of the server, add the variable to \`envVars\` of the agent in \`agentcore/agentcore.json\`. Then run
3967
+ \`agentcore deploy\`. For example:
3968
+
3969
+ \`\`\`json
3970
+ "envVars": [
3971
+ { "name": "BMA_MAX_TURN_LEASE", "value": "600" },
3972
+ { "name": "BMA_CODEX_BINARY", "value": "/opt/bma/bin/codex" }
3973
+ ]
3974
+ \`\`\`
3975
+
3976
+ | Variable | Default | Description |
3977
+ | --- | --- | --- |
3978
+ | \`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. |
3979
+ | \`BMA_STATE_DIR\` | \`.bma\` in \`BMA_HOME_DIR\` | The directory of \`state.json\`. |
3980
+ | \`BMA_HOME_DIR\` | The home directory of the image user | \`HOME\` of the exec-server. |
3981
+ | \`BMA_CODEX_HOME\` | \`.codex\` in \`BMA_HOME_DIR\` | \`CODEX_HOME\` of the exec-server. |
3982
+ | \`BMA_CODEX_BINARY\` | \`/opt/bma/bin/codex\` | The Codex binary. |
3983
+ | \`BMA_MAX_TURN_LEASE\` | \`300\` | The longest turn lease, in seconds. The server changes a longer request to this value. |
3984
+
3985
+
3986
+ # Skills and plugins
3987
+
3988
+ BMA finds skills and plugins in the directories that the client gives in \`capability_directories\`. A directory can be
3989
+ at any absolute path in the ACR. BMA does not need a copy in the workspace.
3990
+
3991
+ - To add a skill to the image, put a plugin under \`plugins/\` and add its path under \`/opt/bma/plugins\` to
3992
+ \`CAPABILITY_DIRECTORIES\` in \`client.py\`. A plugin has a \`.codex-plugin/plugin.json\` file and a \`skills/\` directory
3993
+ with one directory for each skill. Each skill directory has a \`SKILL.md\` file.
3994
+ - To share skills from an S3 bucket, mount an S3 Files access point, for example at \`/mnt/skills\`, and add that
3995
+ path to \`CAPABILITY_DIRECTORIES\`. Add the mount to \`filesystemConfigurations\` in \`agentcore/agentcore.json\` as an
3996
+ \`s3FilesAccessPoint\` entry. An S3 Files mount needs VPC network mode.
3997
+
3998
+ # Deploy
3999
+
4000
+ \`\`\`bash
4001
+ agentcore deploy
4002
+ \`\`\`
4003
+
4004
+ Give the ACR ARN to BMA when you create the BMA environment.
4005
+
4006
+ # Run the client
4007
+
4008
+ Run the client from this directory with the ACR ARN from \`agentcore status\`:
4009
+
4010
+ \`\`\`bash
4011
+ uv run client.py --runtime <ACR ARN>
4012
+ \`\`\`
4013
+
4014
+ \`uv run\` installs the dependencies and the \`dev\` group from \`pyproject.toml\` in \`.venv\`. The client creates a BMA
4015
+ session and prints the session ID, each command with its output, and the answer of the agent as it streams.
4016
+
4017
+ To send another input to the same session, add the BMA session ID. If the session does not exist, the client creates a
4018
+ new session and prints its ID.
4019
+
4020
+ \`\`\`bash
4021
+ uv run client.py --runtime <ACR ARN> --session-id <BMA session ID> --input "List the files in the workspace."
4022
+ \`\`\`
4023
+
4024
+ If you add a Gateway, add \`--gateway <Gateway MCP URL>\` with the URL from the output of \`agentcore deploy\`. BMA adds
4025
+ the Gateway tools when it creates the session, so the flag has no effect on a session that exists.
4026
+
4027
+ To delete the session after the turn, add \`--delete\`. To print each stream event as JSON, add \`--raw\`.
4028
+ "
4029
+ `;
4030
+
4031
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/bma-acr-policy.json should match snapshot 1`] = `
4032
+ "{
4033
+ "Version": "2012-10-17",
4034
+ "Statement": [
4035
+ {
4036
+ "Sid": "AttachToBmaEnvironment",
4037
+ "Effect": "Allow",
4038
+ "Action": ["bedrock-mantle:RegisterEnvironment", "bedrock-mantle:ConnectEnvironment"],
4039
+ "Resource": "arn:*:bedrock-mantle:*:*:project/*"
4040
+ }
4041
+ ]
4042
+ }
4043
+ "
4044
+ `;
4045
+
4046
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/client.py should match snapshot 1`] = `
4047
+ """"Send an input to a Bedrock Managed Agents session that uses this project's ACR."""
4048
+
4049
+ import argparse
4050
+ import json
4051
+ from typing import Any
4052
+
4053
+ from aws_bedrock_token_generator import provide_token
4054
+ from openai import NotFoundError, OpenAI
4055
+
4056
+ BMA_MODEL_ID = "openai.gpt-5.6-luna"
4057
+ WORKSPACE_DIRECTORY = "/home/app/workspace"
4058
+ CAPABILITY_DIRECTORIES = ["/opt/bma/plugins"]
4059
+ TURN_END = ("completed", "failed", "cancelled")
4060
+ TOOL_CALLS = ("mcp_call", "function_call", "web_search_call")
4061
+
4062
+
4063
+ def show(data: dict[str, Any]) -> None:
4064
+ """Prints the session ID, the commands, the tool calls, and the answer."""
4065
+ kind = data["type"].removeprefix("agent.session.")
4066
+ item = data.get("item") or {}
4067
+ if kind == "created":
4068
+ print(f"Session {data['session']['id']}")
4069
+ elif kind == "turn.output_text.delta":
4070
+ print(data["delta"], end="", flush=True)
4071
+ elif kind == "turn.item.done" and item.get("type") == "command_execution":
4072
+ print(f"\\n$ {item['command']}\\n{item.get('output') or ''}".rstrip())
4073
+ elif kind == "turn.item.done" and item.get("type") in TOOL_CALLS:
4074
+ print(f"\\nTool {item.get('name') or item['type']} {item.get('status')}")
4075
+ elif kind == "error" or kind.split(".")[-1] in TURN_END:
4076
+ source = data.get("turn") or data.get("environment") or data.get("session")
4077
+ print(f"\\n{kind} {(source or data).get('error') or ''}".rstrip())
4078
+
4079
+
4080
+ def main() -> None:
4081
+ parser = argparse.ArgumentParser(description=__doc__)
4082
+ parser.add_argument("--runtime", required=True, help="The ACR ARN.")
4083
+ parser.add_argument(
4084
+ "--session-id",
4085
+ help="The BMA session ID. If it does not exist, the client creates a session.",
4086
+ )
4087
+ parser.add_argument(
4088
+ "--input",
4089
+ default="Use the acr-report skill to save an ACR report in the workspace.",
4090
+ )
4091
+ parser.add_argument(
4092
+ "--gateway",
4093
+ help="The Gateway URL from the output of \`agentcore deploy\`.",
4094
+ )
4095
+ parser.add_argument("--delete", action="store_true", help="Delete the session.")
4096
+ parser.add_argument("--raw", action="store_true", help="Print events as JSON.")
4097
+ args = parser.parse_args()
4098
+ # BMA must run in the Region of the ACR.
4099
+ region = args.runtime.split(":")[3]
4100
+
4101
+ with OpenAI(
4102
+ api_key=lambda: provide_token(region=region),
4103
+ base_url=f"https://bedrock-mantle.{region}.api.aws/openai/v1",
4104
+ ) as client:
4105
+ sessions = client.beta.agents.sessions
4106
+ session_id = args.session_id
4107
+ if session_id:
4108
+ try:
4109
+ session = sessions.retrieve(session_id).model_dump(warnings=False)
4110
+ except NotFoundError:
4111
+ print(f"Session {session_id} does not exist.")
4112
+ session_id = None
4113
+ else:
4114
+ if session["environment"].get("runtime_arn") != args.runtime:
4115
+ raise ValueError(f"Session {session_id} uses another ACR.")
4116
+
4117
+ if session_id:
4118
+ # BMA opens the stream only with stream=true, and the SDK does not send it.
4119
+ events = sessions.events.stream(session_id, extra_query={"stream": "true"})
4120
+ message = {
4121
+ "role": "user",
4122
+ "content": [{"type": "input_text", "text": args.input}],
4123
+ }
4124
+ sessions.events.create(
4125
+ session_id,
4126
+ events=[{"type": "agent.session.input.message", "input": [message]}],
4127
+ )
4128
+ else:
4129
+ agent: dict[str, Any] = {
4130
+ "model": BMA_MODEL_ID,
4131
+ "instructions": "Use the available tools to complete the task.",
4132
+ }
4133
+ if args.gateway:
4134
+ # Bedrock Managed Agents calls Gateway with IAM from the service side.
4135
+ agent["tools"] = [
4136
+ {
4137
+ "type": "mcp",
4138
+ "server_label": "team_tools",
4139
+ "required": True,
4140
+ "connection_origin": "service",
4141
+ "transport": {"type": "http", "server_url": args.gateway},
4142
+ }
4143
+ ]
4144
+ args.input += (
4145
+ " Then use the Gateway's documentation and runbook tools to explain"
4146
+ " how to investigate an MCP connection failure. Cite your sources."
4147
+ )
4148
+ events = sessions.create(
4149
+ agent=agent,
4150
+ environment={
4151
+ "type": "aws_bedrock_agentcore",
4152
+ "runtime_arn": args.runtime,
4153
+ "runtime_qualifier": "DEFAULT",
4154
+ "workspace_directory": WORKSPACE_DIRECTORY,
4155
+ "capability_directories": CAPABILITY_DIRECTORIES,
4156
+ },
4157
+ input=args.input,
4158
+ stream=True,
4159
+ )
4160
+
4161
+ with events:
4162
+ for event in events:
4163
+ data = event.model_dump(mode="json", warnings=False)
4164
+ session_id = session_id or (data.get("session") or {}).get("id")
4165
+ if args.raw:
4166
+ print(json.dumps(data), flush=True)
4167
+ else:
4168
+ show(data)
4169
+ if data["type"].removeprefix("agent.session.turn.") in TURN_END:
4170
+ break
4171
+
4172
+ if args.delete:
4173
+ sessions.delete(session_id)
4174
+ print(f"\\nDeleted session {session_id}")
4175
+
4176
+
4177
+ if __name__ == "__main__":
4178
+ main()
4179
+ "
4180
+ `;
4181
+
4182
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/lifecycle/server.py should match snapshot 1`] = `
4183
+ """"AgentCore Runtime (ACR) lifecycle server for a BMA exec-server.
4184
+
4185
+ AgentCore calls \`\`GET /ping\`\` for health and \`\`POST /invocations\`\` for the BMA
4186
+ lifecycle calls \`\`activate\`\`, \`\`renew_turn_lease\`\`, \`\`release_turn_lease\`\`, and
4187
+ \`\`disconnect\`\`. An invocation with no action returns the status.
4188
+
4189
+ The server runs one \`\`codex exec-server\`\` for the attachment of the last
4190
+ \`\`activate\`\` call and saves the attachment in \`\`state.json\`\` in \`\`BMA_STATE_DIR\`\`.
4191
+ The default is \`\`.bma\`\` in the home directory. If the server cannot create
4192
+ \`\`BMA_STATE_DIR\`\`, it keeps \`\`state.json\`\` in \`\`.bma\`\` in the home directory.
4193
+ AgentCore mounts session storage only when an invocation starts, so the server
4194
+ reads \`\`state.json\`\` on the first invocation. On replacement compute, that
4195
+ invocation starts the exec-server again.
4196
+ """
4197
+
4198
+ from __future__ import annotations
4199
+
4200
+ import contextlib
4201
+ import json
4202
+ import os
4203
+ import shlex
4204
+ import signal
4205
+ import subprocess
4206
+ import threading
4207
+ import time
4208
+ from datetime import datetime, timezone
4209
+ from http import HTTPStatus
4210
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4211
+ from pathlib import Path
4212
+ from typing import Any
4213
+
4214
+ from opentelemetry import propagate, trace
4215
+
4216
+ BMA_NAME = "bma-acr-lifecycle"
4217
+ BMA_VERSION = "0.1.0"
4218
+ TRACER = trace.get_tracer(BMA_NAME, BMA_VERSION)
4219
+
4220
+ BMA_CODEX_BINARY = Path(os.environ.get("BMA_CODEX_BINARY", "/opt/bma/bin/codex"))
4221
+ BMA_HOME_DIR = Path(os.environ.get("BMA_HOME_DIR", str(Path.home())))
4222
+ BMA_STATE_DIR = Path(os.environ.get("BMA_STATE_DIR", str(BMA_HOME_DIR / ".bma")))
4223
+ BMA_CODEX_HOME = Path(os.environ.get("BMA_CODEX_HOME", str(BMA_HOME_DIR / ".codex")))
4224
+ BMA_MAX_TURN_LEASE = max(1, int(os.environ.get("BMA_MAX_TURN_LEASE", "300")))
4225
+
4226
+ AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
4227
+ BMA_REMOTE_SERVICE = "bedrock-mantle"
4228
+
4229
+ COLLECTOR_BINARY = Path("/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent")
4230
+ COLLECTOR_CONFIG = Path("/opt/bma/otel/collector.yaml")
4231
+ COLLECTOR_ENDPOINT = "http://127.0.0.1:4318"
4232
+ COLLECTOR_COMMAND = [
4233
+ str(COLLECTOR_BINARY),
4234
+ "-config",
4235
+ os.devnull,
4236
+ "-otelconfig",
4237
+ str(COLLECTOR_CONFIG),
4238
+ ]
4239
+ OBSERVABILITY_ENABLED = (
4240
+ os.environ.get("AGENT_OBSERVABILITY_ENABLED", "").lower() == "true"
4241
+ )
4242
+ EXPORTER = '{otlp-http={endpoint="%s/v1/%s",protocol="binary"}}'
4243
+ TELEMETRY_OVERRIDES = [
4244
+ "-c",
4245
+ "otel.trace_exporter=" + EXPORTER % (COLLECTOR_ENDPOINT, "traces"),
4246
+ "-c",
4247
+ "otel.exporter=" + EXPORTER % (COLLECTOR_ENDPOINT, "logs"),
4248
+ ]
4249
+
4250
+ EXEC_SERVER_ENVIRONMENT = {
4251
+ "RUST_LOG": "codex_exec_server=info,codex_cli=info",
4252
+ "RUST_BACKTRACE": "1",
4253
+ "HOME": str(BMA_HOME_DIR),
4254
+ "CODEX_HOME": str(BMA_CODEX_HOME),
4255
+ "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "false",
4256
+ "OTEL_PYTHON_LOG_CORRELATION": "false",
4257
+ }
4258
+
4259
+ PROTOCOL_VERSION = 1
4260
+ RESTART_DELAY_SECONDS = 5
4261
+ STOP_GRACE_SECONDS = 3
4262
+
4263
+
4264
+ def usable_dir(preferred: Path, fallback: Path) -> Path:
4265
+ """Creates and returns preferred, or fallback if the server cannot create preferred."""
4266
+ try:
4267
+ preferred.mkdir(parents=True, exist_ok=True)
4268
+ return preferred
4269
+ except OSError as error:
4270
+ log("directory_fallback", directory=str(preferred), error=str(error))
4271
+ fallback.mkdir(parents=True, exist_ok=True)
4272
+ return fallback
4273
+
4274
+
4275
+ def state_dir() -> Path:
4276
+ home = BMA_HOME_DIR / ".bma"
4277
+ return usable_dir(BMA_STATE_DIR if BMA_STATE_DIR.parent.is_dir() else home, home)
4278
+
4279
+
4280
+ def now() -> str:
4281
+ return datetime.now(timezone.utc).isoformat()
4282
+
4283
+
4284
+ def log(message: str, **details: Any) -> None:
4285
+ record = {"timestamp": now(), "runtimeSessionId": LIFECYCLE.runtime_session_id}
4286
+ print(json.dumps({**record, "message": message, **details}), flush=True)
4287
+
4288
+
4289
+ def step_span(name: str, **attributes: Any) -> Any:
4290
+ """Starts a child span of the current span. The span has the session ID for the console.
4291
+
4292
+ A step outside a BMA call, for example an exec-server restart by the monitor thread, gets no span, so it does not
4293
+ start a new trace.
4294
+ """
4295
+ if not trace.get_current_span().get_span_context().is_valid:
4296
+ return contextlib.nullcontext(trace.INVALID_SPAN)
4297
+ if LIFECYCLE.runtime_session_id:
4298
+ attributes["session.id"] = LIFECYCLE.runtime_session_id
4299
+ return TRACER.start_as_current_span(name, attributes=attributes)
4300
+
4301
+
4302
+ def failed(
4303
+ span: trace.Span, message: str, error: Exception | str, **details: Any
4304
+ ) -> None:
4305
+ """Logs a failed step and sets the error on its span."""
4306
+ log(message, **details, error=str(error))
4307
+ if isinstance(error, Exception):
4308
+ span.record_exception(error)
4309
+ span.set_status(trace.StatusCode.ERROR, str(error))
4310
+
4311
+
4312
+ def valid_generation(generation: Any) -> bool:
4313
+ return type(generation) is int and generation >= 0
4314
+
4315
+
4316
+ def parse_attachment(request: dict[str, Any]) -> tuple[str, int] | None:
4317
+ """Returns the environment ID and the attachment generation of a BMA call."""
4318
+ version = request.get("protocol_version")
4319
+ environment_id = request.get("environment_id")
4320
+ generation = request.get("attachment_generation")
4321
+ if type(version) is not int or version != PROTOCOL_VERSION:
4322
+ return None
4323
+ if not isinstance(environment_id, str) or not environment_id:
4324
+ return None
4325
+ if not valid_generation(generation):
4326
+ return None
4327
+ return environment_id, generation
4328
+
4329
+
4330
+ BAD_ATTACHMENT = {
4331
+ "error": (
4332
+ f"protocol_version must be {PROTOCOL_VERSION}, environment_id is required, and "
4333
+ "attachment_generation must be a non-negative integer"
4334
+ )
4335
+ }
4336
+ EXEC_SERVER_DOWN = {"error": "the exec-server did not start"}
4337
+ ATTACHMENT_TEXT_FIELDS = (
4338
+ "environment_id",
4339
+ "workspace_directory",
4340
+ "endpoint",
4341
+ "region",
4342
+ "service",
4343
+ )
4344
+
4345
+
4346
+ def valid_attachment(attachment: Any) -> bool:
4347
+ """Returns whether an attachment from activate or state.json can start the exec-server."""
4348
+ if not isinstance(attachment, dict):
4349
+ return False
4350
+ if not valid_generation(attachment.get("attachment_generation")):
4351
+ return False
4352
+ return all(
4353
+ isinstance(attachment.get(field), str)
4354
+ and attachment[field]
4355
+ and "\\0" not in attachment[field]
4356
+ for field in ATTACHMENT_TEXT_FIELDS
4357
+ )
4358
+
4359
+
4360
+ class Lifecycle:
4361
+ """Keeps the attachment, the exec-server, the collector, and the turn leases."""
4362
+
4363
+ def __init__(self) -> None:
4364
+ self.lock = threading.Lock()
4365
+ self.runtime_session_id: str | None = None
4366
+ self.state_loaded = False
4367
+ self.attachment: dict[str, Any] | None = None
4368
+ self.process: subprocess.Popen[str] | None = None
4369
+ self.collector: subprocess.Popen[bytes] | None = None
4370
+ self.leases: dict[str, float] = {}
4371
+
4372
+ def load_state(self, runtime_session_id: str | None) -> None:
4373
+ """Starts the exec-server for the saved attachment on the first invocation."""
4374
+ with self.lock:
4375
+ if runtime_session_id:
4376
+ self.runtime_session_id = runtime_session_id
4377
+ if self.state_loaded:
4378
+ return
4379
+ self.state_loaded = True
4380
+ with step_span("bma.state_load") as span:
4381
+ try:
4382
+ saved = json.loads(
4383
+ (state_dir() / "state.json").read_text(encoding="utf-8")
4384
+ )
4385
+ except FileNotFoundError:
4386
+ span.set_attribute("bma.state.found", False)
4387
+ return
4388
+ except (OSError, ValueError) as error:
4389
+ failed(span, "state_load_failed", error)
4390
+ return
4391
+ span.set_attribute("bma.state.found", True)
4392
+ attachment = (
4393
+ saved.get("attachment") if isinstance(saved, dict) else None
4394
+ )
4395
+ if valid_attachment(attachment):
4396
+ self.attachment = attachment
4397
+ log("state_loaded", **attachment)
4398
+ self._ensure_running_locked("state_loaded")
4399
+ elif attachment is not None:
4400
+ failed(
4401
+ span, "state_load_failed", "state.json has a wrong attachment"
4402
+ )
4403
+
4404
+ def _save_locked(self) -> None:
4405
+ with step_span("bma.state_save") as span:
4406
+ try:
4407
+ directory = state_dir()
4408
+ temporary = directory / "state.json.tmp"
4409
+ temporary.write_text(
4410
+ json.dumps({"attachment": self.attachment}), encoding="utf-8"
4411
+ )
4412
+ temporary.replace(directory / "state.json")
4413
+ except OSError as error:
4414
+ failed(span, "state_save_failed", error)
4415
+
4416
+ def activate(self, request: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
4417
+ parsed = parse_attachment(request)
4418
+ if parsed is None:
4419
+ return HTTPStatus.BAD_REQUEST, BAD_ATTACHMENT
4420
+ workspace = request.get("workspace")
4421
+ directory = workspace.get("directory") if isinstance(workspace, dict) else None
4422
+ if not isinstance(directory, str) or not Path(directory).is_absolute():
4423
+ return HTTPStatus.BAD_REQUEST, {
4424
+ "error": "workspace.directory must be an absolute path"
4425
+ }
4426
+ with step_span(
4427
+ "bma.workspace_check", **{"bma.workspace.directory": directory}
4428
+ ) as span:
4429
+ try:
4430
+ Path(directory).mkdir(parents=True, exist_ok=True)
4431
+ except OSError as error:
4432
+ failed(span, "workspace_unavailable", error, directory=directory)
4433
+ return HTTPStatus.SERVICE_UNAVAILABLE, {
4434
+ "error": f"cannot create workspace.directory: {error}"
4435
+ }
4436
+ environment_id, generation = parsed
4437
+ region = request.get("region") or AWS_REGION
4438
+ attachment = {
4439
+ "environment_id": environment_id,
4440
+ "attachment_generation": generation,
4441
+ "workspace_directory": directory,
4442
+ "endpoint": request.get("endpoint")
4443
+ or f"https://{BMA_REMOTE_SERVICE}.{region}.api.aws",
4444
+ "region": region,
4445
+ "service": request.get("service") or BMA_REMOTE_SERVICE,
4446
+ }
4447
+ if not valid_attachment(attachment):
4448
+ return HTTPStatus.BAD_REQUEST, {
4449
+ "error": "workspace.directory, endpoint, region, and service must be strings"
4450
+ }
4451
+
4452
+ with self.lock:
4453
+ current_id, current_generation = self._key()
4454
+ if current_id == environment_id and generation < current_generation:
4455
+ return self._conflict("stale attachment generation")
4456
+ same = (current_id, current_generation) == parsed
4457
+ if not (same and self._running()):
4458
+ self._stop_locked("activate")
4459
+ if not same:
4460
+ self.leases.clear()
4461
+ self.attachment = attachment
4462
+ self._save_locked()
4463
+ if not self._ensure_running_locked("activate"):
4464
+ return HTTPStatus.SERVICE_UNAVAILABLE, EXEC_SERVER_DOWN
4465
+
4466
+ return HTTPStatus.OK, {
4467
+ "protocol_version": PROTOCOL_VERSION,
4468
+ "environment_id": environment_id,
4469
+ "attachment_generation": generation,
4470
+ "workspace": "ready",
4471
+ "exec_server": "connecting",
4472
+ "runtime_session_id": self.runtime_session_id,
4473
+ }
4474
+
4475
+ def disconnect(self) -> tuple[HTTPStatus, dict[str, Any]]:
4476
+ with self.lock:
4477
+ self._stop_locked("disconnect")
4478
+ self.attachment = None
4479
+ self.leases.clear()
4480
+ self._save_locked()
4481
+ return HTTPStatus.OK, {
4482
+ "protocol_version": PROTOCOL_VERSION,
4483
+ "exec_server": "stopped",
4484
+ }
4485
+
4486
+ def renew_turn_lease(
4487
+ self, request: dict[str, Any]
4488
+ ) -> tuple[HTTPStatus, dict[str, Any]]:
4489
+ turn_id = request.get("turn_id")
4490
+ duration = request.get("lease_duration_seconds")
4491
+ if (
4492
+ not isinstance(turn_id, str)
4493
+ or not turn_id
4494
+ or type(duration) is not int
4495
+ or duration < 1
4496
+ ):
4497
+ return HTTPStatus.BAD_REQUEST, {
4498
+ "error": "turn_id and a positive integer lease_duration_seconds are required"
4499
+ }
4500
+ accepted = min(duration, BMA_MAX_TURN_LEASE)
4501
+ with self.lock:
4502
+ if error := self._check_lease_locked(request):
4503
+ return error
4504
+ if not self._ensure_running_locked("renew_turn_lease"):
4505
+ return HTTPStatus.SERVICE_UNAVAILABLE, EXEC_SERVER_DOWN
4506
+ deadline = time.monotonic() + accepted
4507
+ self.leases[turn_id] = max(self.leases.get(turn_id, deadline), deadline)
4508
+ body = self._lease_body(turn_id, lease_duration_seconds=accepted)
4509
+ log(
4510
+ "turn_lease_renewed",
4511
+ turnId=turn_id,
4512
+ leaseDurationSeconds=accepted,
4513
+ activeLeases=body["active_lease_count"],
4514
+ )
4515
+ return HTTPStatus.OK, body
4516
+
4517
+ def release_turn_lease(
4518
+ self, request: dict[str, Any]
4519
+ ) -> tuple[HTTPStatus, dict[str, Any]]:
4520
+ turn_id = request.get("turn_id")
4521
+ if not isinstance(turn_id, str) or not turn_id:
4522
+ return HTTPStatus.BAD_REQUEST, {"error": "turn_id is required"}
4523
+ with self.lock:
4524
+ if error := self._check_lease_locked(request):
4525
+ return error
4526
+ released = self.leases.pop(turn_id, None) is not None
4527
+ body = self._lease_body(turn_id, released=released)
4528
+ log(
4529
+ "turn_lease_released",
4530
+ turnId=turn_id,
4531
+ released=released,
4532
+ activeLeases=body["active_lease_count"],
4533
+ )
4534
+ return HTTPStatus.OK, body
4535
+
4536
+ def _lease_body(self, turn_id: str, **fields: Any) -> dict[str, Any]:
4537
+ environment_id, generation = self._key()
4538
+ return {
4539
+ "protocol_version": PROTOCOL_VERSION,
4540
+ "environment_id": environment_id,
4541
+ "attachment_generation": generation,
4542
+ "turn_id": turn_id,
4543
+ **fields,
4544
+ "active_lease_count": len(self.leases),
4545
+ }
4546
+
4547
+ def _check_lease_locked(
4548
+ self, request: dict[str, Any]
4549
+ ) -> tuple[HTTPStatus, dict[str, Any]] | None:
4550
+ parsed = parse_attachment(request)
4551
+ if parsed is None:
4552
+ return HTTPStatus.BAD_REQUEST, BAD_ATTACHMENT
4553
+ with step_span("bma.attachment_check") as span:
4554
+ matched = self._key() == parsed
4555
+ span.set_attribute("bma.attachment.matched", matched)
4556
+ if not matched:
4557
+ return self._conflict("turn lease does not match the active attachment")
4558
+ self._prune_locked()
4559
+ return None
4560
+
4561
+ def _conflict(self, message: str) -> tuple[HTTPStatus, dict[str, Any]]:
4562
+ environment_id, generation = self._key()
4563
+ return HTTPStatus.CONFLICT, {
4564
+ "error": message,
4565
+ "environment_id": environment_id,
4566
+ "attachment_generation": generation,
4567
+ }
4568
+
4569
+ def _key(self) -> tuple[str | None, int | None]:
4570
+ """Returns the environment ID and the generation of the attachment."""
4571
+ attachment = self.attachment or {}
4572
+ return attachment.get("environment_id"), attachment.get("attachment_generation")
4573
+
4574
+ def _prune_locked(self) -> None:
4575
+ cutoff = time.monotonic()
4576
+ self.leases = {turn: end for turn, end in self.leases.items() if end > cutoff}
4577
+
4578
+ def health(self) -> str:
4579
+ """Does not take the lock, so an exec-server stop does not delay /ping."""
4580
+ cutoff = time.monotonic()
4581
+ busy = any(end > cutoff for end in self.leases.copy().values())
4582
+ return "HealthyBusy" if busy else "Healthy"
4583
+
4584
+ def status(self) -> dict[str, Any]:
4585
+ with self.lock:
4586
+ self._prune_locked()
4587
+ return {
4588
+ "service": f"{BMA_NAME}/{BMA_VERSION}",
4589
+ "runtimeSessionId": self.runtime_session_id,
4590
+ "attachment": self.attachment,
4591
+ "execServerRunning": self._running(),
4592
+ "activeLeaseCount": len(self.leases),
4593
+ }
4594
+
4595
+ def _running(self) -> bool:
4596
+ return self.process is not None and self.process.poll() is None
4597
+
4598
+ def _ensure_running_locked(self, reason: str) -> bool:
4599
+ """Starts the exec-server for the attachment if it does not run. Returns whether it runs."""
4600
+ attachment = self.attachment
4601
+ if attachment is None:
4602
+ return False
4603
+ if self._running():
4604
+ return True
4605
+ with step_span("bma.exec_server_start", **{"bma.reason": reason}) as span:
4606
+ command = [
4607
+ str(BMA_CODEX_BINARY),
4608
+ *(TELEMETRY_OVERRIDES if self._ensure_collector_locked() else []),
4609
+ "exec-server",
4610
+ "--remote",
4611
+ f"{attachment['endpoint'].rstrip('/')}/v1",
4612
+ "--environment-id",
4613
+ attachment["environment_id"],
4614
+ "--remote-transport",
4615
+ "direct",
4616
+ "--aws-sigv4",
4617
+ "--aws-service",
4618
+ attachment["service"],
4619
+ "--aws-region",
4620
+ attachment["region"],
4621
+ ]
4622
+ try:
4623
+ workspace = Path(attachment["workspace_directory"])
4624
+ workspace.mkdir(parents=True, exist_ok=True)
4625
+ BMA_HOME_DIR.mkdir(parents=True, exist_ok=True)
4626
+ BMA_CODEX_HOME.mkdir(parents=True, exist_ok=True)
4627
+ log(
4628
+ "exec_server_start",
4629
+ reason=reason,
4630
+ command=shlex.join(command),
4631
+ cwd=str(workspace),
4632
+ **attachment,
4633
+ )
4634
+ self.process = subprocess.Popen(
4635
+ command,
4636
+ cwd=workspace,
4637
+ env={**os.environ, **EXEC_SERVER_ENVIRONMENT},
4638
+ stdout=subprocess.PIPE,
4639
+ stderr=subprocess.STDOUT,
4640
+ text=True,
4641
+ bufsize=1,
4642
+ start_new_session=True,
4643
+ )
4644
+ except (OSError, ValueError) as error:
4645
+ self.process = None
4646
+ failed(span, "exec_server_start_failed", error)
4647
+ return False
4648
+ span.set_attribute("process.pid", self.process.pid)
4649
+ threading.Thread(target=self._drain, args=(self.process,), daemon=True).start()
4650
+ return True
4651
+
4652
+ def _ensure_collector_locked(self) -> bool:
4653
+ """Starts the collector if it does not run. Returns whether it gets the Codex spans and logs."""
4654
+ if not OBSERVABILITY_ENABLED or not self.runtime_session_id:
4655
+ return False
4656
+ if self.collector is not None and self.collector.poll() is None:
4657
+ return True
4658
+ environment = {
4659
+ **os.environ,
4660
+ "AGENTCORE_RUNTIME_SID": self.runtime_session_id,
4661
+ }
4662
+ with step_span("bma.collector_start") as span:
4663
+ try:
4664
+ self.collector = subprocess.Popen(COLLECTOR_COMMAND, env=environment)
4665
+ except OSError as error:
4666
+ failed(span, "collector_start_failed", error)
4667
+ return False
4668
+ span.set_attribute("process.pid", self.collector.pid)
4669
+ log("collector_start", pid=self.collector.pid)
4670
+ return True
4671
+
4672
+ def _stop_locked(self, reason: str) -> None:
4673
+ process, self.process = self.process, None
4674
+ if process is None or process.poll() is not None:
4675
+ return
4676
+ log("exec_server_stop", reason=reason, pid=process.pid)
4677
+ with step_span(
4678
+ "bma.exec_server_stop", **{"bma.reason": reason, "process.pid": process.pid}
4679
+ ) as span:
4680
+ process.terminate()
4681
+ try:
4682
+ process.wait(timeout=STOP_GRACE_SECONDS)
4683
+ except subprocess.TimeoutExpired:
4684
+ process.kill()
4685
+ process.wait()
4686
+ span.set_attribute("bma.killed", True)
4687
+
4688
+ @staticmethod
4689
+ def _drain(process: subprocess.Popen[str]) -> None:
4690
+ for line in process.stdout or []:
4691
+ log("exec_server_output", line=line.rstrip("\\n"))
4692
+ log("exec_server_exited", returnCode=process.wait())
4693
+
4694
+ def monitor(self) -> None:
4695
+ """Starts the exec-server again if it stops while an attachment is active."""
4696
+ while True:
4697
+ time.sleep(RESTART_DELAY_SECONDS)
4698
+ with self.lock:
4699
+ self._ensure_running_locked("monitor")
4700
+
4701
+ def shutdown(self) -> None:
4702
+ with self.lock:
4703
+ self._stop_locked("shutdown")
4704
+ if self.collector is not None:
4705
+ self.collector.terminate()
4706
+
4707
+ def dispatch(
4708
+ self, action: str, request: dict[str, Any]
4709
+ ) -> tuple[HTTPStatus, dict[str, Any]]:
4710
+ handlers = {
4711
+ "activate": self.activate,
4712
+ "renew_turn_lease": self.renew_turn_lease,
4713
+ "release_turn_lease": self.release_turn_lease,
4714
+ "disconnect": lambda _: self.disconnect(),
4715
+ "status": lambda _: (HTTPStatus.OK, self.status()),
4716
+ }
4717
+ if action not in handlers:
4718
+ return HTTPStatus.BAD_REQUEST, {"error": f"unknown action: {action}"}
4719
+ return handlers[action](request)
4720
+
4721
+
4722
+ LIFECYCLE = Lifecycle()
4723
+
4724
+
4725
+ class Handler(BaseHTTPRequestHandler):
4726
+ server_version = f"{BMA_NAME}/{BMA_VERSION}"
4727
+ protocol_version = "HTTP/1.1"
4728
+
4729
+ def send_json(self, status: HTTPStatus, body: dict[str, Any]) -> None:
4730
+ """Sends HTTP 200, because the Runtime drops the body of any other status."""
4731
+ if status != HTTPStatus.OK:
4732
+ body = {**body, "status_code": int(status)}
4733
+ encoded = json.dumps(body).encode("utf-8")
4734
+ self.send_response(HTTPStatus.OK)
4735
+ self.send_header("Content-Type", "application/json")
4736
+ self.send_header("Content-Length", str(len(encoded)))
4737
+ self.end_headers()
4738
+ self.wfile.write(encoded)
4739
+
4740
+ def read_body(self) -> bytes:
4741
+ """Reads the request body, with Content-Length or chunked transfer encoding."""
4742
+ if "chunked" not in self.headers.get("Transfer-Encoding", "").lower():
4743
+ length = int(self.headers.get("Content-Length") or 0)
4744
+ return self.rfile.read(length) if length > 0 else b""
4745
+ chunks = []
4746
+ while size := int(self.rfile.readline().split(b";")[0], 16):
4747
+ chunks.append(self.rfile.read(size))
4748
+ self.rfile.readline()
4749
+ # Read the trailer section to the empty line that ends the request.
4750
+ while self.rfile.readline().strip():
4751
+ pass
4752
+ return b"".join(chunks)
4753
+
4754
+ def do_GET(self) -> None:
4755
+ if self.path == "/ping":
4756
+ self.send_json(HTTPStatus.OK, {"status": LIFECYCLE.health()})
4757
+ else:
4758
+ self.send_json(HTTPStatus.NOT_FOUND, {"error": "route not found"})
4759
+
4760
+ def do_POST(self) -> None:
4761
+ if self.path != "/invocations":
4762
+ self.close_connection = True
4763
+ self.send_json(HTTPStatus.NOT_FOUND, {"error": "route not found"})
4764
+ return
4765
+ try:
4766
+ body = self.read_body()
4767
+ request = json.loads(body) if body else {}
4768
+ except ValueError:
4769
+ request = None
4770
+ if not isinstance(request, dict):
4771
+ self.close_connection = True
4772
+ self.send_json(
4773
+ HTTPStatus.BAD_REQUEST, {"error": "request body must be a JSON object"}
4774
+ )
4775
+ return
4776
+ runtime_session_id = self.headers.get(
4777
+ "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"
4778
+ )
4779
+ action = str(request.get("action", "status"))
4780
+ with TRACER.start_as_current_span(
4781
+ "bma.invocation",
4782
+ context=propagate.extract(self.headers),
4783
+ kind=trace.SpanKind.SERVER,
4784
+ attributes={"bma.action": action},
4785
+ ) as span:
4786
+ if runtime_session_id:
4787
+ span.set_attribute("session.id", runtime_session_id)
4788
+ try:
4789
+ LIFECYCLE.load_state(runtime_session_id)
4790
+ status, body = LIFECYCLE.dispatch(action, request)
4791
+ except Exception as error:
4792
+ log("dispatch_failed", action=action, error=str(error))
4793
+ span.record_exception(error)
4794
+ status, body = (
4795
+ HTTPStatus.INTERNAL_SERVER_ERROR,
4796
+ {"error": "internal error"},
4797
+ )
4798
+ span.set_attribute("http.response.status_code", int(status))
4799
+ if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
4800
+ span.set_status(trace.StatusCode.ERROR)
4801
+ self.send_json(status, body)
4802
+
4803
+ def log_message(self, message_format: str, *args: Any) -> None:
4804
+ if getattr(self, "path", None) == "/ping":
4805
+ return
4806
+ log("http", client=self.client_address[0], request=message_format % args)
4807
+
4808
+
4809
+ def serve() -> None:
4810
+ def handle(signal_number: int, _frame: Any) -> None:
4811
+ log("signal", signal=signal_number)
4812
+ LIFECYCLE.shutdown()
4813
+ raise SystemExit(0)
4814
+
4815
+ signal.signal(signal.SIGTERM, handle)
4816
+ signal.signal(signal.SIGINT, handle)
4817
+ threading.Thread(target=LIFECYCLE.monitor, daemon=True).start()
4818
+ log("server_ready", port=8080, stateDir=str(BMA_STATE_DIR))
4819
+ ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
4820
+
4821
+
4822
+ if __name__ == "__main__":
4823
+ serve()
4824
+ "
4825
+ `;
4826
+
4827
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/otel/collector.yaml should match snapshot 1`] = `
4828
+ "# The CloudWatch agent runs this OpenTelemetry Collector configuration. It receives OTLP from
4829
+ # the exec-server, puts the runtime session ID on each span and log, and signs each request
4830
+ # with the ACR role. The exec-server sends the log group headers that the Runtime sets in
4831
+ # OTEL_EXPORTER_OTLP_TRACES_HEADERS and OTEL_EXPORTER_OTLP_LOGS_HEADERS, and the collector
4832
+ # sends them on to X-Ray and CloudWatch Logs.
4833
+ receivers:
4834
+ otlp:
4835
+ protocols:
4836
+ http:
4837
+ endpoint: 127.0.0.1:4318
4838
+ include_metadata: true
4839
+ processors:
4840
+ attributes:
4841
+ actions:
4842
+ - key: session.id
4843
+ value: \${env:AGENTCORE_RUNTIME_SID}
4844
+ action: upsert
4845
+ exporters:
4846
+ otlphttp/traces:
4847
+ traces_endpoint: https://xray.\${env:AWS_REGION}.amazonaws.com/v1/traces
4848
+ auth:
4849
+ authenticator: headers_setter/traces
4850
+ otlphttp/logs:
4851
+ logs_endpoint: https://logs.\${env:AWS_REGION}.amazonaws.com/v1/logs
4852
+ auth:
4853
+ authenticator: headers_setter/logs
4854
+ extensions:
4855
+ sigv4auth/xray:
4856
+ region: \${env:AWS_REGION}
4857
+ service: xray
4858
+ sigv4auth/logs:
4859
+ region: \${env:AWS_REGION}
4860
+ service: logs
4861
+ headers_setter/traces:
4862
+ additional_auth: sigv4auth/xray
4863
+ headers:
4864
+ - key: x-aws-log-group
4865
+ from_context: x-aws-log-group
4866
+ action: upsert
4867
+ - key: x-aws-log-stream
4868
+ from_context: x-aws-log-stream
4869
+ action: upsert
4870
+ headers_setter/logs:
4871
+ additional_auth: sigv4auth/logs
4872
+ headers:
4873
+ - key: x-aws-log-group
4874
+ from_context: x-aws-log-group
4875
+ action: upsert
4876
+ - key: x-aws-log-stream
4877
+ from_context: x-aws-log-stream
4878
+ action: upsert
4879
+ - key: x-aws-metric-namespace
4880
+ from_context: x-aws-metric-namespace
4881
+ action: upsert
4882
+ service:
4883
+ extensions:
4884
+ - sigv4auth/xray
4885
+ - sigv4auth/logs
4886
+ - headers_setter/traces
4887
+ - headers_setter/logs
4888
+ pipelines:
4889
+ traces:
4890
+ receivers: [otlp]
4891
+ processors: [attributes]
4892
+ exporters: [otlphttp/traces]
4893
+ logs:
4894
+ receivers: [otlp]
4895
+ processors: [attributes]
4896
+ exporters: [otlphttp/logs]
4897
+ "
4898
+ `;
4899
+
4900
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/plugins/acr-report/.codex-plugin/plugin.json should match snapshot 1`] = `
4901
+ "{
4902
+ "name": "acr-report",
4903
+ "version": "0.1.0",
4904
+ "description": "Report the Python version, user, and working directory of the ACR.",
4905
+ "skills": "./skills/"
4906
+ }
4907
+ "
4908
+ `;
4909
+
4910
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/plugins/acr-report/skills/acr-report/SKILL.md should match snapshot 1`] = `
4911
+ "---
4912
+ name: acr-report
4913
+ description: Save a report of the Python version, user ID, and working directory of the ACR in the workspace.
4914
+ ---
4915
+
4916
+ Run these commands in the workspace directory:
4917
+
4918
+ - \`python3 --version\`
4919
+ - \`id -u\`
4920
+ - \`pwd\`
4921
+
4922
+ Save the output as \`acr-report.txt\` in the workspace. The first line of the
4923
+ file is \`# ACR report\`, and each command and its output follow it. Read the
4924
+ file back and report its path.
4925
+
4926
+ If a command fails, put its error in the report. Do not change the commands.
4927
+ "
4928
+ `;
4929
+
4930
+ exports[`Assets Directory Snapshots > Python framework assets > python/python/http/bma/base/pyproject.toml should match snapshot 1`] = `
4931
+ "[project]
4932
+ name = "{{ name }}"
4933
+ version = "0.1.0"
4934
+ description = "AgentCore Runtime environment for Bedrock Managed Agents"
4935
+ readme = "README.md"
4936
+ dependencies = [
4937
+ "aws-opentelemetry-distro",
4938
+ "bedrock-agentcore",
4939
+ ]
4940
+
4941
+ # The dependencies of client.py. The image build runs \`uv sync --no-dev\`.
4942
+ [dependency-groups]
4943
+ dev = [
4944
+ "aws-bedrock-token-generator>=1.1.0",
4945
+ "openai>=3.16.2",
4946
+ ]
4947
+
4948
+ [tool.uv]
4949
+ package = false
4950
+ "
4951
+ `;
4952
+
3851
4953
  exports[`Assets Directory Snapshots > Python framework assets > python/python/http/googleadk/base/README.md should match snapshot 1`] = `
3852
4954
  "This is a project generated by the agentcore create CLI tool!
3853
4955