@aws/agentcore 0.30.0 → 0.31.0
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.
- package/README.md +7 -6
- package/dist/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +1103 -1
- package/dist/assets/cdk/package.json +1 -1
- package/dist/assets/python/http/bma/base/Dockerfile +41 -0
- package/dist/assets/python/http/bma/base/README.md +122 -0
- package/dist/assets/python/http/bma/base/bma-acr-policy.json +11 -0
- package/dist/assets/python/http/bma/base/client.py +132 -0
- package/dist/assets/python/http/bma/base/lifecycle/server.py +641 -0
- package/dist/assets/python/http/bma/base/otel/collector.yaml +69 -0
- package/dist/assets/python/http/bma/base/plugins/acr-report/.codex-plugin/plugin.json +6 -0
- package/dist/assets/python/http/bma/base/plugins/acr-report/skills/acr-report/SKILL.md +16 -0
- package/dist/assets/python/http/bma/base/pyproject.toml +19 -0
- package/dist/cli/index.mjs +570 -635
- package/dist/schema/constants.d.ts +4 -1
- package/dist/schema/constants.d.ts.map +1 -1
- package/dist/schema/constants.js +22 -3
- package/dist/schema/constants.js.map +1 -1
- package/npm-shrinkwrap.json +3 -30
- package/package.json +2 -2
|
@@ -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.
|
|
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
|
|