@aws/agentcore 0.29.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.
@@ -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()