@aws/agentcore 1.0.0-preview.30 → 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.
- 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
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""AgentCore Runtime (ACR) lifecycle server for a BMA exec-server.
|
|
2
|
+
|
|
3
|
+
AgentCore calls ``GET /ping`` for health and ``POST /invocations`` for the BMA
|
|
4
|
+
lifecycle calls ``activate``, ``renew_turn_lease``, ``release_turn_lease``, and
|
|
5
|
+
``disconnect``. An invocation with no action returns the status.
|
|
6
|
+
|
|
7
|
+
The server runs one ``codex exec-server`` for the attachment of the last
|
|
8
|
+
``activate`` call and saves the attachment in ``state.json`` in ``BMA_STATE_DIR``.
|
|
9
|
+
The default is ``.bma`` in the home directory. If the server cannot create
|
|
10
|
+
``BMA_STATE_DIR``, it keeps ``state.json`` in ``.bma`` in the home directory.
|
|
11
|
+
AgentCore mounts session storage only when an invocation starts, so the server
|
|
12
|
+
reads ``state.json`` on the first invocation. On replacement compute, that
|
|
13
|
+
invocation starts the exec-server again.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import signal
|
|
23
|
+
import subprocess
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from http import HTTPStatus
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from opentelemetry import propagate, trace
|
|
33
|
+
|
|
34
|
+
BMA_NAME = "bma-acr-lifecycle"
|
|
35
|
+
BMA_VERSION = "0.1.0"
|
|
36
|
+
TRACER = trace.get_tracer(BMA_NAME, BMA_VERSION)
|
|
37
|
+
|
|
38
|
+
BMA_CODEX_BINARY = Path(os.environ.get("BMA_CODEX_BINARY", "/opt/bma/bin/codex"))
|
|
39
|
+
BMA_HOME_DIR = Path(os.environ.get("BMA_HOME_DIR", str(Path.home())))
|
|
40
|
+
BMA_STATE_DIR = Path(os.environ.get("BMA_STATE_DIR", str(BMA_HOME_DIR / ".bma")))
|
|
41
|
+
BMA_CODEX_HOME = Path(os.environ.get("BMA_CODEX_HOME", str(BMA_HOME_DIR / ".codex")))
|
|
42
|
+
BMA_MAX_TURN_LEASE = max(1, int(os.environ.get("BMA_MAX_TURN_LEASE", "300")))
|
|
43
|
+
|
|
44
|
+
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
|
|
45
|
+
BMA_REMOTE_SERVICE = "bedrock-mantle"
|
|
46
|
+
|
|
47
|
+
COLLECTOR_BINARY = Path("/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent")
|
|
48
|
+
COLLECTOR_CONFIG = Path("/opt/bma/otel/collector.yaml")
|
|
49
|
+
COLLECTOR_ENDPOINT = "http://127.0.0.1:4318"
|
|
50
|
+
COLLECTOR_COMMAND = [
|
|
51
|
+
str(COLLECTOR_BINARY),
|
|
52
|
+
"-config",
|
|
53
|
+
os.devnull,
|
|
54
|
+
"-otelconfig",
|
|
55
|
+
str(COLLECTOR_CONFIG),
|
|
56
|
+
]
|
|
57
|
+
OBSERVABILITY_ENABLED = (
|
|
58
|
+
os.environ.get("AGENT_OBSERVABILITY_ENABLED", "").lower() == "true"
|
|
59
|
+
)
|
|
60
|
+
EXPORTER = '{otlp-http={endpoint="%s/v1/%s",protocol="binary"}}'
|
|
61
|
+
TELEMETRY_OVERRIDES = [
|
|
62
|
+
"-c",
|
|
63
|
+
"otel.trace_exporter=" + EXPORTER % (COLLECTOR_ENDPOINT, "traces"),
|
|
64
|
+
"-c",
|
|
65
|
+
"otel.exporter=" + EXPORTER % (COLLECTOR_ENDPOINT, "logs"),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
EXEC_SERVER_ENVIRONMENT = {
|
|
69
|
+
"RUST_LOG": "codex_exec_server=info,codex_cli=info",
|
|
70
|
+
"RUST_BACKTRACE": "1",
|
|
71
|
+
"HOME": str(BMA_HOME_DIR),
|
|
72
|
+
"CODEX_HOME": str(BMA_CODEX_HOME),
|
|
73
|
+
"OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "false",
|
|
74
|
+
"OTEL_PYTHON_LOG_CORRELATION": "false",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
PROTOCOL_VERSION = 1
|
|
78
|
+
RESTART_DELAY_SECONDS = 5
|
|
79
|
+
STOP_GRACE_SECONDS = 3
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def usable_dir(preferred: Path, fallback: Path) -> Path:
|
|
83
|
+
"""Creates and returns preferred, or fallback if the server cannot create preferred."""
|
|
84
|
+
try:
|
|
85
|
+
preferred.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
return preferred
|
|
87
|
+
except OSError as error:
|
|
88
|
+
log("directory_fallback", directory=str(preferred), error=str(error))
|
|
89
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return fallback
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def state_dir() -> Path:
|
|
94
|
+
home = BMA_HOME_DIR / ".bma"
|
|
95
|
+
return usable_dir(BMA_STATE_DIR if BMA_STATE_DIR.parent.is_dir() else home, home)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def now() -> str:
|
|
99
|
+
return datetime.now(timezone.utc).isoformat()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def log(message: str, **details: Any) -> None:
|
|
103
|
+
record = {"timestamp": now(), "runtimeSessionId": LIFECYCLE.runtime_session_id}
|
|
104
|
+
print(json.dumps({**record, "message": message, **details}), flush=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def step_span(name: str, **attributes: Any) -> Any:
|
|
108
|
+
"""Starts a child span of the current span. The span has the session ID for the console.
|
|
109
|
+
|
|
110
|
+
A step outside a BMA call, for example an exec-server restart by the monitor thread, gets no span, so it does not
|
|
111
|
+
start a new trace.
|
|
112
|
+
"""
|
|
113
|
+
if not trace.get_current_span().get_span_context().is_valid:
|
|
114
|
+
return contextlib.nullcontext(trace.INVALID_SPAN)
|
|
115
|
+
if LIFECYCLE.runtime_session_id:
|
|
116
|
+
attributes["session.id"] = LIFECYCLE.runtime_session_id
|
|
117
|
+
return TRACER.start_as_current_span(name, attributes=attributes)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def failed(
|
|
121
|
+
span: trace.Span, message: str, error: Exception | str, **details: Any
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Logs a failed step and sets the error on its span."""
|
|
124
|
+
log(message, **details, error=str(error))
|
|
125
|
+
if isinstance(error, Exception):
|
|
126
|
+
span.record_exception(error)
|
|
127
|
+
span.set_status(trace.StatusCode.ERROR, str(error))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def valid_generation(generation: Any) -> bool:
|
|
131
|
+
return type(generation) is int and generation >= 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_attachment(request: dict[str, Any]) -> tuple[str, int] | None:
|
|
135
|
+
"""Returns the environment ID and the attachment generation of a BMA call."""
|
|
136
|
+
version = request.get("protocol_version")
|
|
137
|
+
environment_id = request.get("environment_id")
|
|
138
|
+
generation = request.get("attachment_generation")
|
|
139
|
+
if type(version) is not int or version != PROTOCOL_VERSION:
|
|
140
|
+
return None
|
|
141
|
+
if not isinstance(environment_id, str) or not environment_id:
|
|
142
|
+
return None
|
|
143
|
+
if not valid_generation(generation):
|
|
144
|
+
return None
|
|
145
|
+
return environment_id, generation
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
BAD_ATTACHMENT = {
|
|
149
|
+
"error": (
|
|
150
|
+
f"protocol_version must be {PROTOCOL_VERSION}, environment_id is required, and "
|
|
151
|
+
"attachment_generation must be a non-negative integer"
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
EXEC_SERVER_DOWN = {"error": "the exec-server did not start"}
|
|
155
|
+
ATTACHMENT_TEXT_FIELDS = (
|
|
156
|
+
"environment_id",
|
|
157
|
+
"workspace_directory",
|
|
158
|
+
"endpoint",
|
|
159
|
+
"region",
|
|
160
|
+
"service",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def valid_attachment(attachment: Any) -> bool:
|
|
165
|
+
"""Returns whether an attachment from activate or state.json can start the exec-server."""
|
|
166
|
+
if not isinstance(attachment, dict):
|
|
167
|
+
return False
|
|
168
|
+
if not valid_generation(attachment.get("attachment_generation")):
|
|
169
|
+
return False
|
|
170
|
+
return all(
|
|
171
|
+
isinstance(attachment.get(field), str)
|
|
172
|
+
and attachment[field]
|
|
173
|
+
and "\0" not in attachment[field]
|
|
174
|
+
for field in ATTACHMENT_TEXT_FIELDS
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Lifecycle:
|
|
179
|
+
"""Keeps the attachment, the exec-server, the collector, and the turn leases."""
|
|
180
|
+
|
|
181
|
+
def __init__(self) -> None:
|
|
182
|
+
self.lock = threading.Lock()
|
|
183
|
+
self.runtime_session_id: str | None = None
|
|
184
|
+
self.state_loaded = False
|
|
185
|
+
self.attachment: dict[str, Any] | None = None
|
|
186
|
+
self.process: subprocess.Popen[str] | None = None
|
|
187
|
+
self.collector: subprocess.Popen[bytes] | None = None
|
|
188
|
+
self.leases: dict[str, float] = {}
|
|
189
|
+
|
|
190
|
+
def load_state(self, runtime_session_id: str | None) -> None:
|
|
191
|
+
"""Starts the exec-server for the saved attachment on the first invocation."""
|
|
192
|
+
with self.lock:
|
|
193
|
+
if runtime_session_id:
|
|
194
|
+
self.runtime_session_id = runtime_session_id
|
|
195
|
+
if self.state_loaded:
|
|
196
|
+
return
|
|
197
|
+
self.state_loaded = True
|
|
198
|
+
with step_span("bma.state_load") as span:
|
|
199
|
+
try:
|
|
200
|
+
saved = json.loads(
|
|
201
|
+
(state_dir() / "state.json").read_text(encoding="utf-8")
|
|
202
|
+
)
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
span.set_attribute("bma.state.found", False)
|
|
205
|
+
return
|
|
206
|
+
except (OSError, ValueError) as error:
|
|
207
|
+
failed(span, "state_load_failed", error)
|
|
208
|
+
return
|
|
209
|
+
span.set_attribute("bma.state.found", True)
|
|
210
|
+
attachment = (
|
|
211
|
+
saved.get("attachment") if isinstance(saved, dict) else None
|
|
212
|
+
)
|
|
213
|
+
if valid_attachment(attachment):
|
|
214
|
+
self.attachment = attachment
|
|
215
|
+
log("state_loaded", **attachment)
|
|
216
|
+
self._ensure_running_locked("state_loaded")
|
|
217
|
+
elif attachment is not None:
|
|
218
|
+
failed(
|
|
219
|
+
span, "state_load_failed", "state.json has a wrong attachment"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _save_locked(self) -> None:
|
|
223
|
+
with step_span("bma.state_save") as span:
|
|
224
|
+
try:
|
|
225
|
+
directory = state_dir()
|
|
226
|
+
temporary = directory / "state.json.tmp"
|
|
227
|
+
temporary.write_text(
|
|
228
|
+
json.dumps({"attachment": self.attachment}), encoding="utf-8"
|
|
229
|
+
)
|
|
230
|
+
temporary.replace(directory / "state.json")
|
|
231
|
+
except OSError as error:
|
|
232
|
+
failed(span, "state_save_failed", error)
|
|
233
|
+
|
|
234
|
+
def activate(self, request: dict[str, Any]) -> tuple[HTTPStatus, dict[str, Any]]:
|
|
235
|
+
parsed = parse_attachment(request)
|
|
236
|
+
if parsed is None:
|
|
237
|
+
return HTTPStatus.BAD_REQUEST, BAD_ATTACHMENT
|
|
238
|
+
workspace = request.get("workspace")
|
|
239
|
+
directory = workspace.get("directory") if isinstance(workspace, dict) else None
|
|
240
|
+
if not isinstance(directory, str) or not Path(directory).is_absolute():
|
|
241
|
+
return HTTPStatus.BAD_REQUEST, {
|
|
242
|
+
"error": "workspace.directory must be an absolute path"
|
|
243
|
+
}
|
|
244
|
+
with step_span(
|
|
245
|
+
"bma.workspace_check", **{"bma.workspace.directory": directory}
|
|
246
|
+
) as span:
|
|
247
|
+
try:
|
|
248
|
+
Path(directory).mkdir(parents=True, exist_ok=True)
|
|
249
|
+
except OSError as error:
|
|
250
|
+
failed(span, "workspace_unavailable", error, directory=directory)
|
|
251
|
+
return HTTPStatus.SERVICE_UNAVAILABLE, {
|
|
252
|
+
"error": f"cannot create workspace.directory: {error}"
|
|
253
|
+
}
|
|
254
|
+
environment_id, generation = parsed
|
|
255
|
+
region = request.get("region") or AWS_REGION
|
|
256
|
+
attachment = {
|
|
257
|
+
"environment_id": environment_id,
|
|
258
|
+
"attachment_generation": generation,
|
|
259
|
+
"workspace_directory": directory,
|
|
260
|
+
"endpoint": request.get("endpoint")
|
|
261
|
+
or f"https://{BMA_REMOTE_SERVICE}.{region}.api.aws",
|
|
262
|
+
"region": region,
|
|
263
|
+
"service": request.get("service") or BMA_REMOTE_SERVICE,
|
|
264
|
+
}
|
|
265
|
+
if not valid_attachment(attachment):
|
|
266
|
+
return HTTPStatus.BAD_REQUEST, {
|
|
267
|
+
"error": "workspace.directory, endpoint, region, and service must be strings"
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
with self.lock:
|
|
271
|
+
current_id, current_generation = self._key()
|
|
272
|
+
if current_id == environment_id and generation < current_generation:
|
|
273
|
+
return self._conflict("stale attachment generation")
|
|
274
|
+
same = (current_id, current_generation) == parsed
|
|
275
|
+
if not (same and self._running()):
|
|
276
|
+
self._stop_locked("activate")
|
|
277
|
+
if not same:
|
|
278
|
+
self.leases.clear()
|
|
279
|
+
self.attachment = attachment
|
|
280
|
+
self._save_locked()
|
|
281
|
+
if not self._ensure_running_locked("activate"):
|
|
282
|
+
return HTTPStatus.SERVICE_UNAVAILABLE, EXEC_SERVER_DOWN
|
|
283
|
+
|
|
284
|
+
return HTTPStatus.OK, {
|
|
285
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
286
|
+
"environment_id": environment_id,
|
|
287
|
+
"attachment_generation": generation,
|
|
288
|
+
"workspace": "ready",
|
|
289
|
+
"exec_server": "connecting",
|
|
290
|
+
"runtime_session_id": self.runtime_session_id,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def disconnect(self) -> tuple[HTTPStatus, dict[str, Any]]:
|
|
294
|
+
with self.lock:
|
|
295
|
+
self._stop_locked("disconnect")
|
|
296
|
+
self.attachment = None
|
|
297
|
+
self.leases.clear()
|
|
298
|
+
self._save_locked()
|
|
299
|
+
return HTTPStatus.OK, {
|
|
300
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
301
|
+
"exec_server": "stopped",
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def renew_turn_lease(
|
|
305
|
+
self, request: dict[str, Any]
|
|
306
|
+
) -> tuple[HTTPStatus, dict[str, Any]]:
|
|
307
|
+
turn_id = request.get("turn_id")
|
|
308
|
+
duration = request.get("lease_duration_seconds")
|
|
309
|
+
if (
|
|
310
|
+
not isinstance(turn_id, str)
|
|
311
|
+
or not turn_id
|
|
312
|
+
or type(duration) is not int
|
|
313
|
+
or duration < 1
|
|
314
|
+
):
|
|
315
|
+
return HTTPStatus.BAD_REQUEST, {
|
|
316
|
+
"error": "turn_id and a positive integer lease_duration_seconds are required"
|
|
317
|
+
}
|
|
318
|
+
accepted = min(duration, BMA_MAX_TURN_LEASE)
|
|
319
|
+
with self.lock:
|
|
320
|
+
if error := self._check_lease_locked(request):
|
|
321
|
+
return error
|
|
322
|
+
if not self._ensure_running_locked("renew_turn_lease"):
|
|
323
|
+
return HTTPStatus.SERVICE_UNAVAILABLE, EXEC_SERVER_DOWN
|
|
324
|
+
deadline = time.monotonic() + accepted
|
|
325
|
+
self.leases[turn_id] = max(self.leases.get(turn_id, deadline), deadline)
|
|
326
|
+
body = self._lease_body(turn_id, lease_duration_seconds=accepted)
|
|
327
|
+
log(
|
|
328
|
+
"turn_lease_renewed",
|
|
329
|
+
turnId=turn_id,
|
|
330
|
+
leaseDurationSeconds=accepted,
|
|
331
|
+
activeLeases=body["active_lease_count"],
|
|
332
|
+
)
|
|
333
|
+
return HTTPStatus.OK, body
|
|
334
|
+
|
|
335
|
+
def release_turn_lease(
|
|
336
|
+
self, request: dict[str, Any]
|
|
337
|
+
) -> tuple[HTTPStatus, dict[str, Any]]:
|
|
338
|
+
turn_id = request.get("turn_id")
|
|
339
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
340
|
+
return HTTPStatus.BAD_REQUEST, {"error": "turn_id is required"}
|
|
341
|
+
with self.lock:
|
|
342
|
+
if error := self._check_lease_locked(request):
|
|
343
|
+
return error
|
|
344
|
+
released = self.leases.pop(turn_id, None) is not None
|
|
345
|
+
body = self._lease_body(turn_id, released=released)
|
|
346
|
+
log(
|
|
347
|
+
"turn_lease_released",
|
|
348
|
+
turnId=turn_id,
|
|
349
|
+
released=released,
|
|
350
|
+
activeLeases=body["active_lease_count"],
|
|
351
|
+
)
|
|
352
|
+
return HTTPStatus.OK, body
|
|
353
|
+
|
|
354
|
+
def _lease_body(self, turn_id: str, **fields: Any) -> dict[str, Any]:
|
|
355
|
+
environment_id, generation = self._key()
|
|
356
|
+
return {
|
|
357
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
358
|
+
"environment_id": environment_id,
|
|
359
|
+
"attachment_generation": generation,
|
|
360
|
+
"turn_id": turn_id,
|
|
361
|
+
**fields,
|
|
362
|
+
"active_lease_count": len(self.leases),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
def _check_lease_locked(
|
|
366
|
+
self, request: dict[str, Any]
|
|
367
|
+
) -> tuple[HTTPStatus, dict[str, Any]] | None:
|
|
368
|
+
parsed = parse_attachment(request)
|
|
369
|
+
if parsed is None:
|
|
370
|
+
return HTTPStatus.BAD_REQUEST, BAD_ATTACHMENT
|
|
371
|
+
with step_span("bma.attachment_check") as span:
|
|
372
|
+
matched = self._key() == parsed
|
|
373
|
+
span.set_attribute("bma.attachment.matched", matched)
|
|
374
|
+
if not matched:
|
|
375
|
+
return self._conflict("turn lease does not match the active attachment")
|
|
376
|
+
self._prune_locked()
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
def _conflict(self, message: str) -> tuple[HTTPStatus, dict[str, Any]]:
|
|
380
|
+
environment_id, generation = self._key()
|
|
381
|
+
return HTTPStatus.CONFLICT, {
|
|
382
|
+
"error": message,
|
|
383
|
+
"environment_id": environment_id,
|
|
384
|
+
"attachment_generation": generation,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
def _key(self) -> tuple[str | None, int | None]:
|
|
388
|
+
"""Returns the environment ID and the generation of the attachment."""
|
|
389
|
+
attachment = self.attachment or {}
|
|
390
|
+
return attachment.get("environment_id"), attachment.get("attachment_generation")
|
|
391
|
+
|
|
392
|
+
def _prune_locked(self) -> None:
|
|
393
|
+
cutoff = time.monotonic()
|
|
394
|
+
self.leases = {turn: end for turn, end in self.leases.items() if end > cutoff}
|
|
395
|
+
|
|
396
|
+
def health(self) -> str:
|
|
397
|
+
"""Does not take the lock, so an exec-server stop does not delay /ping."""
|
|
398
|
+
cutoff = time.monotonic()
|
|
399
|
+
busy = any(end > cutoff for end in self.leases.copy().values())
|
|
400
|
+
return "HealthyBusy" if busy else "Healthy"
|
|
401
|
+
|
|
402
|
+
def status(self) -> dict[str, Any]:
|
|
403
|
+
with self.lock:
|
|
404
|
+
self._prune_locked()
|
|
405
|
+
return {
|
|
406
|
+
"service": f"{BMA_NAME}/{BMA_VERSION}",
|
|
407
|
+
"runtimeSessionId": self.runtime_session_id,
|
|
408
|
+
"attachment": self.attachment,
|
|
409
|
+
"execServerRunning": self._running(),
|
|
410
|
+
"activeLeaseCount": len(self.leases),
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
def _running(self) -> bool:
|
|
414
|
+
return self.process is not None and self.process.poll() is None
|
|
415
|
+
|
|
416
|
+
def _ensure_running_locked(self, reason: str) -> bool:
|
|
417
|
+
"""Starts the exec-server for the attachment if it does not run. Returns whether it runs."""
|
|
418
|
+
attachment = self.attachment
|
|
419
|
+
if attachment is None:
|
|
420
|
+
return False
|
|
421
|
+
if self._running():
|
|
422
|
+
return True
|
|
423
|
+
with step_span("bma.exec_server_start", **{"bma.reason": reason}) as span:
|
|
424
|
+
command = [
|
|
425
|
+
str(BMA_CODEX_BINARY),
|
|
426
|
+
*(TELEMETRY_OVERRIDES if self._ensure_collector_locked() else []),
|
|
427
|
+
"exec-server",
|
|
428
|
+
"--remote",
|
|
429
|
+
f"{attachment['endpoint'].rstrip('/')}/v1",
|
|
430
|
+
"--environment-id",
|
|
431
|
+
attachment["environment_id"],
|
|
432
|
+
"--remote-transport",
|
|
433
|
+
"direct",
|
|
434
|
+
"--aws-sigv4",
|
|
435
|
+
"--aws-service",
|
|
436
|
+
attachment["service"],
|
|
437
|
+
"--aws-region",
|
|
438
|
+
attachment["region"],
|
|
439
|
+
]
|
|
440
|
+
try:
|
|
441
|
+
workspace = Path(attachment["workspace_directory"])
|
|
442
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
443
|
+
BMA_HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
BMA_CODEX_HOME.mkdir(parents=True, exist_ok=True)
|
|
445
|
+
log(
|
|
446
|
+
"exec_server_start",
|
|
447
|
+
reason=reason,
|
|
448
|
+
command=shlex.join(command),
|
|
449
|
+
cwd=str(workspace),
|
|
450
|
+
**attachment,
|
|
451
|
+
)
|
|
452
|
+
self.process = subprocess.Popen(
|
|
453
|
+
command,
|
|
454
|
+
cwd=workspace,
|
|
455
|
+
env={**os.environ, **EXEC_SERVER_ENVIRONMENT},
|
|
456
|
+
stdout=subprocess.PIPE,
|
|
457
|
+
stderr=subprocess.STDOUT,
|
|
458
|
+
text=True,
|
|
459
|
+
bufsize=1,
|
|
460
|
+
start_new_session=True,
|
|
461
|
+
)
|
|
462
|
+
except (OSError, ValueError) as error:
|
|
463
|
+
self.process = None
|
|
464
|
+
failed(span, "exec_server_start_failed", error)
|
|
465
|
+
return False
|
|
466
|
+
span.set_attribute("process.pid", self.process.pid)
|
|
467
|
+
threading.Thread(target=self._drain, args=(self.process,), daemon=True).start()
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
def _ensure_collector_locked(self) -> bool:
|
|
471
|
+
"""Starts the collector if it does not run. Returns whether it gets the Codex spans and logs."""
|
|
472
|
+
if not OBSERVABILITY_ENABLED or not self.runtime_session_id:
|
|
473
|
+
return False
|
|
474
|
+
if self.collector is not None and self.collector.poll() is None:
|
|
475
|
+
return True
|
|
476
|
+
environment = {
|
|
477
|
+
**os.environ,
|
|
478
|
+
"AGENTCORE_RUNTIME_SID": self.runtime_session_id,
|
|
479
|
+
}
|
|
480
|
+
with step_span("bma.collector_start") as span:
|
|
481
|
+
try:
|
|
482
|
+
self.collector = subprocess.Popen(COLLECTOR_COMMAND, env=environment)
|
|
483
|
+
except OSError as error:
|
|
484
|
+
failed(span, "collector_start_failed", error)
|
|
485
|
+
return False
|
|
486
|
+
span.set_attribute("process.pid", self.collector.pid)
|
|
487
|
+
log("collector_start", pid=self.collector.pid)
|
|
488
|
+
return True
|
|
489
|
+
|
|
490
|
+
def _stop_locked(self, reason: str) -> None:
|
|
491
|
+
process, self.process = self.process, None
|
|
492
|
+
if process is None or process.poll() is not None:
|
|
493
|
+
return
|
|
494
|
+
log("exec_server_stop", reason=reason, pid=process.pid)
|
|
495
|
+
with step_span(
|
|
496
|
+
"bma.exec_server_stop", **{"bma.reason": reason, "process.pid": process.pid}
|
|
497
|
+
) as span:
|
|
498
|
+
process.terminate()
|
|
499
|
+
try:
|
|
500
|
+
process.wait(timeout=STOP_GRACE_SECONDS)
|
|
501
|
+
except subprocess.TimeoutExpired:
|
|
502
|
+
process.kill()
|
|
503
|
+
process.wait()
|
|
504
|
+
span.set_attribute("bma.killed", True)
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _drain(process: subprocess.Popen[str]) -> None:
|
|
508
|
+
for line in process.stdout or []:
|
|
509
|
+
log("exec_server_output", line=line.rstrip("\n"))
|
|
510
|
+
log("exec_server_exited", returnCode=process.wait())
|
|
511
|
+
|
|
512
|
+
def monitor(self) -> None:
|
|
513
|
+
"""Starts the exec-server again if it stops while an attachment is active."""
|
|
514
|
+
while True:
|
|
515
|
+
time.sleep(RESTART_DELAY_SECONDS)
|
|
516
|
+
with self.lock:
|
|
517
|
+
self._ensure_running_locked("monitor")
|
|
518
|
+
|
|
519
|
+
def shutdown(self) -> None:
|
|
520
|
+
with self.lock:
|
|
521
|
+
self._stop_locked("shutdown")
|
|
522
|
+
if self.collector is not None:
|
|
523
|
+
self.collector.terminate()
|
|
524
|
+
|
|
525
|
+
def dispatch(
|
|
526
|
+
self, action: str, request: dict[str, Any]
|
|
527
|
+
) -> tuple[HTTPStatus, dict[str, Any]]:
|
|
528
|
+
handlers = {
|
|
529
|
+
"activate": self.activate,
|
|
530
|
+
"renew_turn_lease": self.renew_turn_lease,
|
|
531
|
+
"release_turn_lease": self.release_turn_lease,
|
|
532
|
+
"disconnect": lambda _: self.disconnect(),
|
|
533
|
+
"status": lambda _: (HTTPStatus.OK, self.status()),
|
|
534
|
+
}
|
|
535
|
+
if action not in handlers:
|
|
536
|
+
return HTTPStatus.BAD_REQUEST, {"error": f"unknown action: {action}"}
|
|
537
|
+
return handlers[action](request)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
LIFECYCLE = Lifecycle()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class Handler(BaseHTTPRequestHandler):
|
|
544
|
+
server_version = f"{BMA_NAME}/{BMA_VERSION}"
|
|
545
|
+
protocol_version = "HTTP/1.1"
|
|
546
|
+
|
|
547
|
+
def send_json(self, status: HTTPStatus, body: dict[str, Any]) -> None:
|
|
548
|
+
"""Sends HTTP 200, because the Runtime drops the body of any other status."""
|
|
549
|
+
if status != HTTPStatus.OK:
|
|
550
|
+
body = {**body, "status_code": int(status)}
|
|
551
|
+
encoded = json.dumps(body).encode("utf-8")
|
|
552
|
+
self.send_response(HTTPStatus.OK)
|
|
553
|
+
self.send_header("Content-Type", "application/json")
|
|
554
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
555
|
+
self.end_headers()
|
|
556
|
+
self.wfile.write(encoded)
|
|
557
|
+
|
|
558
|
+
def read_body(self) -> bytes:
|
|
559
|
+
"""Reads the request body, with Content-Length or chunked transfer encoding."""
|
|
560
|
+
if "chunked" not in self.headers.get("Transfer-Encoding", "").lower():
|
|
561
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
562
|
+
return self.rfile.read(length) if length > 0 else b""
|
|
563
|
+
chunks = []
|
|
564
|
+
while size := int(self.rfile.readline().split(b";")[0], 16):
|
|
565
|
+
chunks.append(self.rfile.read(size))
|
|
566
|
+
self.rfile.readline()
|
|
567
|
+
# Read the trailer section to the empty line that ends the request.
|
|
568
|
+
while self.rfile.readline().strip():
|
|
569
|
+
pass
|
|
570
|
+
return b"".join(chunks)
|
|
571
|
+
|
|
572
|
+
def do_GET(self) -> None:
|
|
573
|
+
if self.path == "/ping":
|
|
574
|
+
self.send_json(HTTPStatus.OK, {"status": LIFECYCLE.health()})
|
|
575
|
+
else:
|
|
576
|
+
self.send_json(HTTPStatus.NOT_FOUND, {"error": "route not found"})
|
|
577
|
+
|
|
578
|
+
def do_POST(self) -> None:
|
|
579
|
+
if self.path != "/invocations":
|
|
580
|
+
self.close_connection = True
|
|
581
|
+
self.send_json(HTTPStatus.NOT_FOUND, {"error": "route not found"})
|
|
582
|
+
return
|
|
583
|
+
try:
|
|
584
|
+
body = self.read_body()
|
|
585
|
+
request = json.loads(body) if body else {}
|
|
586
|
+
except ValueError:
|
|
587
|
+
request = None
|
|
588
|
+
if not isinstance(request, dict):
|
|
589
|
+
self.close_connection = True
|
|
590
|
+
self.send_json(
|
|
591
|
+
HTTPStatus.BAD_REQUEST, {"error": "request body must be a JSON object"}
|
|
592
|
+
)
|
|
593
|
+
return
|
|
594
|
+
runtime_session_id = self.headers.get(
|
|
595
|
+
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id"
|
|
596
|
+
)
|
|
597
|
+
action = str(request.get("action", "status"))
|
|
598
|
+
with TRACER.start_as_current_span(
|
|
599
|
+
"bma.invocation",
|
|
600
|
+
context=propagate.extract(self.headers),
|
|
601
|
+
kind=trace.SpanKind.SERVER,
|
|
602
|
+
attributes={"bma.action": action},
|
|
603
|
+
) as span:
|
|
604
|
+
if runtime_session_id:
|
|
605
|
+
span.set_attribute("session.id", runtime_session_id)
|
|
606
|
+
try:
|
|
607
|
+
LIFECYCLE.load_state(runtime_session_id)
|
|
608
|
+
status, body = LIFECYCLE.dispatch(action, request)
|
|
609
|
+
except Exception as error:
|
|
610
|
+
log("dispatch_failed", action=action, error=str(error))
|
|
611
|
+
span.record_exception(error)
|
|
612
|
+
status, body = (
|
|
613
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
614
|
+
{"error": "internal error"},
|
|
615
|
+
)
|
|
616
|
+
span.set_attribute("http.response.status_code", int(status))
|
|
617
|
+
if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
618
|
+
span.set_status(trace.StatusCode.ERROR)
|
|
619
|
+
self.send_json(status, body)
|
|
620
|
+
|
|
621
|
+
def log_message(self, message_format: str, *args: Any) -> None:
|
|
622
|
+
if getattr(self, "path", None) == "/ping":
|
|
623
|
+
return
|
|
624
|
+
log("http", client=self.client_address[0], request=message_format % args)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def serve() -> None:
|
|
628
|
+
def handle(signal_number: int, _frame: Any) -> None:
|
|
629
|
+
log("signal", signal=signal_number)
|
|
630
|
+
LIFECYCLE.shutdown()
|
|
631
|
+
raise SystemExit(0)
|
|
632
|
+
|
|
633
|
+
signal.signal(signal.SIGTERM, handle)
|
|
634
|
+
signal.signal(signal.SIGINT, handle)
|
|
635
|
+
threading.Thread(target=LIFECYCLE.monitor, daemon=True).start()
|
|
636
|
+
log("server_ready", port=8080, stateDir=str(BMA_STATE_DIR))
|
|
637
|
+
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
if __name__ == "__main__":
|
|
641
|
+
serve()
|