From fa8de6e1d22d88dc1b301c1b8d1e07af1234c56e Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 16 Mar 2026 17:10:16 -0400 Subject: [PATCH 1/9] feat: add A2A protocol support via serve_a2a and build_a2a_app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ~130 lines of Bedrock-specific glue around the official a2a-sdk, replacing the need for a custom protocol implementation. The industry (ADK, Strands, CrewAI) has converged on the a2a-sdk, and this delegates all protocol handling to it. New exports from bedrock_agentcore.runtime: - serve_a2a(executor, agent_card=None, ...) — one-liner to start an A2A server - build_a2a_app(executor, agent_card=None, ...) — returns a Starlette app - BedrockCallContextBuilder — extracts Bedrock headers into contextvars Key features: - Optional a2a-sdk dependency: pip install "bedrock-agentcore[a2a]" - Auto-builds AgentCard from StrandsA2AExecutor when not provided - Auto-populates agent_card.url from AGENTCORE_RUNTIME_URL env var - Docker/container host detection (0.0.0.0 vs 127.0.0.1) - /ping health check endpoint with optional custom handler - Propagates session, request, token, and custom headers via contextvars Works with any framework that provides an a2a-sdk AgentExecutor: - Strands: serve_a2a(StrandsA2AExecutor(agent)) - ADK: serve_a2a(A2aAgentExecutor(runner=runner), card) - LangGraph: serve_a2a(CustomExecutor(graph), card) --- pyproject.toml | 2 + src/bedrock_agentcore/runtime/__init__.py | 4 + src/bedrock_agentcore/runtime/a2a.py | 257 ++++++++++++ src/bedrock_agentcore/runtime/models.py | 1 + tests/bedrock_agentcore/runtime/test_a2a.py | 373 ++++++++++++++++++ .../runtime/test_a2a_integration.py | 244 ++++++++++++ 6 files changed, 881 insertions(+) create mode 100644 src/bedrock_agentcore/runtime/a2a.py create mode 100644 tests/bedrock_agentcore/runtime/test_a2a.py create mode 100644 tests/integration/runtime/test_a2a_integration.py diff --git a/pyproject.toml b/pyproject.toml index 90f910fb..d1d1e72f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,9 +150,11 @@ dev = [ "wheel>=0.45.1", "strands-agents>=1.18.0", "strands-agents-evals>=0.1.0", + "a2a-sdk[http-server]>=0.3", ] [project.optional-dependencies] +a2a = ["a2a-sdk[http-server]>=0.3"] strands-agents = [ "strands-agents>=1.1.0" ] diff --git a/src/bedrock_agentcore/runtime/__init__.py b/src/bedrock_agentcore/runtime/__init__.py index b86c8aaa..ee849981 100644 --- a/src/bedrock_agentcore/runtime/__init__.py +++ b/src/bedrock_agentcore/runtime/__init__.py @@ -6,6 +6,7 @@ - BedrockAgentCoreContext: Agent identity context """ +from .a2a import BedrockCallContextBuilder, build_a2a_app, serve_a2a from .agent_core_runtime_client import AgentCoreRuntimeClient from .app import BedrockAgentCoreApp from .context import BedrockAgentCoreContext, RequestContext @@ -14,7 +15,10 @@ __all__ = [ "AgentCoreRuntimeClient", "BedrockAgentCoreApp", + "BedrockCallContextBuilder", "RequestContext", "BedrockAgentCoreContext", "PingStatus", + "build_a2a_app", + "serve_a2a", ] diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py new file mode 100644 index 00000000..4aa66348 --- /dev/null +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -0,0 +1,257 @@ +"""A2A protocol support for Bedrock AgentCore Runtime. + +Provides Bedrock-specific glue around the official a2a-sdk, handling header +extraction, health checks, and Docker host detection. +""" + +import logging +import time +import uuid +from typing import Any, Callable, Optional + +from .context import BedrockAgentCoreContext +from .models import ( + ACCESS_TOKEN_HEADER, + AGENTCORE_RUNTIME_URL_ENV, + AUTHORIZATION_HEADER, + CUSTOM_HEADER_PREFIX, + OAUTH2_CALLBACK_URL_HEADER, + REQUEST_ID_HEADER, + SESSION_HEADER, + PingStatus, +) + +logger = logging.getLogger(__name__) + + +def _check_a2a_sdk() -> None: + """Raise ImportError with install instructions if a2a-sdk is missing.""" + try: + import a2a # noqa: F401 + except ImportError: + raise ImportError( + 'a2a-sdk is required for A2A protocol support. Install it with: pip install "bedrock-agentcore[a2a]"' + ) from None + + +def _build_agent_card(executor: Any, url: str) -> Any: + """Build an AgentCard by introspecting a StrandsA2AExecutor. + + Extracts name/description from ``executor.agent``. Falls back to generic + defaults for other executors. + """ + from a2a.types import AgentCapabilities, AgentCard, AgentSkill + + name = "agent" + description = "A Bedrock AgentCore agent" + + agent = getattr(executor, "agent", None) + if agent is not None: + name = getattr(agent, "name", None) or name + description = getattr(agent, "description", None) or description + + return AgentCard( + name=name, + description=description, + url=url, + version="0.1.0", + capabilities=AgentCapabilities(streaming=True), + skills=[AgentSkill(id="main", name=name, description=description, tags=["main"])], + default_input_modes=["text"], + default_output_modes=["text"], + ) + + +class BedrockCallContextBuilder: + """Extracts Bedrock runtime headers and propagates them into BedrockAgentCoreContext. + + Implements the a2a-sdk CallContextBuilder ABC so the A2A server + automatically calls ``build()`` on every incoming request. + """ + + def build(self, request: Any) -> Any: + """Build a ServerCallContext from a Starlette Request. + + Args: + request: A Starlette Request object. + + Returns: + A ServerCallContext with Bedrock headers stored in ``state``. + """ + from a2a.server.context import ServerCallContext + + headers = request.headers + + request_id = headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4()) + session_id = headers.get(SESSION_HEADER) + BedrockAgentCoreContext.set_request_context(request_id, session_id) + + workload_access_token = headers.get(ACCESS_TOKEN_HEADER) + if workload_access_token: + BedrockAgentCoreContext.set_workload_access_token(workload_access_token) + + oauth2_callback_url = headers.get(OAUTH2_CALLBACK_URL_HEADER) + if oauth2_callback_url: + BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url) + + request_headers: dict[str, str] = {} + authorization_header = headers.get(AUTHORIZATION_HEADER) + if authorization_header is not None: + request_headers[AUTHORIZATION_HEADER] = authorization_header + for header_name, header_value in headers.items(): + if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()): + request_headers[header_name] = header_value + if request_headers: + BedrockAgentCoreContext.set_request_headers(request_headers) + + state = { + "request_id": request_id, + "session_id": session_id, + } + if workload_access_token: + state["workload_access_token"] = workload_access_token + if oauth2_callback_url: + state["oauth2_callback_url"] = oauth2_callback_url + + return ServerCallContext(state=state) + + +# Register as a virtual subclass so isinstance checks pass without +# requiring a2a-sdk to be importable at class-definition time. +try: + from a2a.server.apps import CallContextBuilder + + CallContextBuilder.register(BedrockCallContextBuilder) +except Exception: # pragma: no cover + pass + + +def build_a2a_app( + executor: Any, + agent_card: Any = None, + *, + task_store: Any = None, + context_builder: Any = None, + ping_handler: Optional[Callable[[], PingStatus]] = None, +) -> Any: + """Build a Starlette app wired for A2A protocol with Bedrock extras. + + Args: + executor: An ``AgentExecutor`` that implements the agent logic. + agent_card: Optional ``a2a.types.AgentCard`` describing the agent. + If ``None``, one is built automatically by introspecting the executor. + task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``. + context_builder: Optional ``CallContextBuilder``; defaults to + ``BedrockCallContextBuilder``. + ping_handler: Optional callback returning a ``PingStatus``. + + Returns: + A Starlette application. + """ + import os + + _check_a2a_sdk() + + from starlette.responses import JSONResponse + from starlette.routing import Route + + from a2a.server.apps import A2AStarletteApplication + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.tasks import InMemoryTaskStore + + runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, "http://localhost:9000/") + + if agent_card is None: + agent_card = _build_agent_card(executor, runtime_url) + elif os.environ.get(AGENTCORE_RUNTIME_URL_ENV): + agent_card.url = runtime_url + + if task_store is None: + task_store = InMemoryTaskStore() + if context_builder is None: + context_builder = BedrockCallContextBuilder() + + http_handler = DefaultRequestHandler( + agent_executor=executor, + task_store=task_store, + ) + + a2a_app = A2AStarletteApplication( + agent_card=agent_card, + http_handler=http_handler, + context_builder=context_builder, + ) + + app = a2a_app.build() + + last_status_update_time = time.time() + + def _handle_ping(request: Any) -> JSONResponse: + nonlocal last_status_update_time + try: + if ping_handler is not None: + status = ping_handler() + else: + status = PingStatus.HEALTHY + last_status_update_time = time.time() + except Exception: + logger.exception("Custom ping handler failed, falling back to Healthy") + status = PingStatus.HEALTHY + return JSONResponse({"status": status.value, "time_of_last_update": int(last_status_update_time)}) + + app.routes.append(Route("/ping", _handle_ping, methods=["GET"])) + + return app + + +def serve_a2a( + executor: Any, + agent_card: Any = None, + *, + port: int = 9000, + host: Optional[str] = None, + task_store: Any = None, + context_builder: Any = None, + ping_handler: Optional[Callable[[], PingStatus]] = None, + **kwargs: Any, +) -> None: + """Start a Bedrock-compatible A2A server. + + Args: + executor: An ``AgentExecutor`` that implements the agent logic. + agent_card: Optional ``a2a.types.AgentCard`` describing the agent. + If ``None``, one is built automatically by introspecting the executor. + port: Port to serve on (default 9000). + host: Host to bind to; auto-detected if ``None``. + task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``. + context_builder: Optional ``CallContextBuilder``; defaults to + ``BedrockCallContextBuilder``. + ping_handler: Optional callback returning a ``PingStatus``. + **kwargs: Additional arguments forwarded to ``uvicorn.run()``. + """ + import os + + import uvicorn + + app = build_a2a_app( + executor, + agent_card, + task_store=task_store, + context_builder=context_builder, + ping_handler=ping_handler, + ) + + if host is None: + if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_CONTAINER"): + host = "0.0.0.0" # nosec B104 - Container needs this to expose the port + else: + host = "127.0.0.1" + + uvicorn_params: dict[str, Any] = { + "host": host, + "port": port, + "log_level": "info", + } + uvicorn_params.update(kwargs) + + uvicorn.run(app, **uvicorn_params) diff --git a/src/bedrock_agentcore/runtime/models.py b/src/bedrock_agentcore/runtime/models.py index 7de61fc6..6fc33f9c 100644 --- a/src/bedrock_agentcore/runtime/models.py +++ b/src/bedrock_agentcore/runtime/models.py @@ -20,6 +20,7 @@ class PingStatus(str, Enum): OAUTH2_CALLBACK_URL_HEADER = "OAuth2CallbackUrl" AUTHORIZATION_HEADER = "Authorization" CUSTOM_HEADER_PREFIX = "X-Amzn-Bedrock-AgentCore-Runtime-Custom-" +AGENTCORE_RUNTIME_URL_ENV = "AGENTCORE_RUNTIME_URL" # Task action constants TASK_ACTION_PING_STATUS = "ping_status" diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py new file mode 100644 index 00000000..a2a16a41 --- /dev/null +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -0,0 +1,373 @@ +import contextvars +import uuid +from unittest.mock import patch + +from starlette.testclient import TestClient + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import InMemoryTaskStore, TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart +from a2a.utils import new_task +from bedrock_agentcore.runtime.a2a import ( + BedrockCallContextBuilder, + build_a2a_app, + serve_a2a, +) +from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +from bedrock_agentcore.runtime.models import PingStatus + + +class _EchoExecutor(AgentExecutor): + """Echoes user input back as an artifact. Records call_context for inspection.""" + + def __init__(self): + self.last_call_context = None + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + self.last_call_context = context.call_context + task = context.current_task or new_task(context.message) + if not context.current_task: + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + user_text = context.get_user_input() + await updater.add_artifact([Part(root=TextPart(text=f"echo: {user_text}"))]) + await updater.complete() + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + pass + + +def _make_agent_card() -> AgentCard: + return AgentCard( + name="test-agent", + description="A test agent", + url="http://localhost:9000", + version="1.0.0", + capabilities=AgentCapabilities(streaming=True), + skills=[AgentSkill(id="echo", name="echo", description="Echoes input", tags=["echo"])], + default_input_modes=["text"], + default_output_modes=["text"], + ) + + +def _jsonrpc_request(method: str, params: dict | None = None) -> dict: + body: dict = {"jsonrpc": "2.0", "method": method, "id": 1} + if params is not None: + body["params"] = params + return body + + +def _send_message_params(text: str = "hello") -> dict: + return { + "message": { + "message_id": str(uuid.uuid4()), + "role": "user", + "parts": [{"kind": "text", "text": text}], + } + } + + +class TestBuildA2AApp: + def test_ping_returns_healthy_with_timestamp(self): + app = build_a2a_app(_EchoExecutor(), _make_agent_card()) + client = TestClient(app) + resp = client.get("/ping") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "Healthy" + assert isinstance(body["time_of_last_update"], int) + assert body["time_of_last_update"] > 0 + + def test_custom_ping_handler(self): + app = build_a2a_app( + _EchoExecutor(), + _make_agent_card(), + ping_handler=lambda: PingStatus.HEALTHY_BUSY, + ) + client = TestClient(app) + resp = client.get("/ping") + assert resp.status_code == 200 + assert resp.json()["status"] == "HealthyBusy" + + def test_ping_fallback_on_error(self): + def bad_ping(): + raise RuntimeError("boom") + + app = build_a2a_app( + _EchoExecutor(), + _make_agent_card(), + ping_handler=bad_ping, + ) + client = TestClient(app) + resp = client.get("/ping") + assert resp.status_code == 200 + assert resp.json()["status"] == "Healthy" + + def test_agent_card_returns_card_data(self): + app = build_a2a_app(_EchoExecutor(), _make_agent_card()) + client = TestClient(app) + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "test-agent" + assert body["version"] == "1.0.0" + assert body["skills"][0]["id"] == "echo" + + def test_message_send_executes_and_returns_completed_task(self): + """Verify the full RPC path: JSON-RPC request -> executor -> completed task.""" + app = build_a2a_app(_EchoExecutor(), _make_agent_card()) + client = TestClient(app, raise_server_exceptions=False) + resp = client.post("/", json=_jsonrpc_request("message/send", _send_message_params("unit-test"))) + assert resp.status_code == 200 + body = resp.json() + assert "result" in body + task = body["result"] + assert task["status"]["state"] == "completed" + assert task["artifacts"][0]["parts"][0]["text"] == "echo: unit-test" + + def test_custom_task_store_is_used_for_persistence(self): + """Verify that a custom task_store actually stores the task.""" + store = InMemoryTaskStore() + app = build_a2a_app(_EchoExecutor(), _make_agent_card(), task_store=store) + client = TestClient(app, raise_server_exceptions=False) + + # Send a message so a task gets created + resp = client.post("/", json=_jsonrpc_request("message/send", _send_message_params("store-test"))) + task_id = resp.json()["result"]["id"] + + # Retrieve from the same store via tasks/get + resp2 = client.post("/", json=_jsonrpc_request("tasks/get", {"id": task_id})) + assert resp2.json()["result"]["id"] == task_id + + def test_agent_card_url_auto_populated_from_env(self): + """When AGENTCORE_RUNTIME_URL is set, agent_card.url is overridden.""" + card = _make_agent_card() + assert card.url == "http://localhost:9000" + with patch.dict("os.environ", {"AGENTCORE_RUNTIME_URL": "https://deployed.example.com/"}): + build_a2a_app(_EchoExecutor(), card) + assert card.url == "https://deployed.example.com/" + + def test_agent_card_url_unchanged_without_env(self): + """When AGENTCORE_RUNTIME_URL is not set, agent_card.url stays as-is.""" + card = _make_agent_card() + original_url = card.url + with patch.dict("os.environ", {}, clear=False): + import os + + os.environ.pop("AGENTCORE_RUNTIME_URL", None) + build_a2a_app(_EchoExecutor(), card) + assert card.url == original_url + + def test_auto_builds_card_when_none_provided(self): + """When agent_card is omitted, a default card is built automatically.""" + app = build_a2a_app(_EchoExecutor()) + client = TestClient(app) + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "agent" + assert body["url"] == "http://localhost:9000/" + + def test_auto_builds_card_from_strands_executor(self): + """When executor has .agent with name/description, card is built from it.""" + + class _FakeAgent: + name = "My Strands Agent" + description = "Does cool stuff" + + executor = _EchoExecutor() + executor.agent = _FakeAgent() # type: ignore[attr-defined] + + app = build_a2a_app(executor) + client = TestClient(app) + resp = client.get("/.well-known/agent-card.json") + body = resp.json() + assert body["name"] == "My Strands Agent" + assert body["description"] == "Does cool stuff" + + def test_auto_card_uses_runtime_url_env(self): + """Auto-built card picks up AGENTCORE_RUNTIME_URL.""" + with patch.dict("os.environ", {"AGENTCORE_RUNTIME_URL": "https://prod.example.com/"}): + app = build_a2a_app(_EchoExecutor()) + client = TestClient(app) + resp = client.get("/.well-known/agent-card.json") + assert resp.json()["url"] == "https://prod.example.com/" + + +class TestBedrockCallContextBuilder: + def _run_in_isolated_context(self, fn): + """Run fn in a fresh contextvars.Context so tests don't leak state.""" + ctx = contextvars.copy_context() + return ctx.run(fn) + + def test_extracts_all_bedrock_headers(self): + """Verify every Bedrock header is extracted into both ServerCallContext.state and contextvars.""" + + def _test(): + from starlette.requests import Request + + scope = { + "type": "http", + "method": "POST", + "path": "/", + "headers": [ + (b"x-amzn-bedrock-agentcore-runtime-session-id", b"sess-abc"), + (b"x-amzn-bedrock-agentcore-runtime-request-id", b"req-456"), + (b"workloadaccesstoken", b"tok-xyz"), + (b"oauth2callbackurl", b"https://callback.example.com"), + (b"x-amzn-bedrock-agentcore-runtime-custom-foo", b"bar"), + (b"authorization", b"Bearer test-token"), + ], + "query_string": b"", + } + request = Request(scope) + builder = BedrockCallContextBuilder() + result = builder.build(request) + + # Check ServerCallContext state dict + assert result.state["session_id"] == "sess-abc" + assert result.state["request_id"] == "req-456" + assert result.state["workload_access_token"] == "tok-xyz" + assert result.state["oauth2_callback_url"] == "https://callback.example.com" + + # Check contextvars were actually set + assert BedrockAgentCoreContext.get_session_id() == "sess-abc" + assert BedrockAgentCoreContext.get_request_id() == "req-456" + assert BedrockAgentCoreContext.get_workload_access_token() == "tok-xyz" + assert BedrockAgentCoreContext.get_oauth2_callback_url() == "https://callback.example.com" + + # Check custom + authorization headers collected + headers = BedrockAgentCoreContext.get_request_headers() + assert headers is not None + assert "Authorization" in headers or "authorization" in headers + custom_found = any(k.lower().startswith("x-amzn-bedrock-agentcore-runtime-custom-") for k in headers) + assert custom_found + + self._run_in_isolated_context(_test) + + def test_auto_generates_request_id_when_missing(self): + def _test(): + from starlette.requests import Request + + scope = { + "type": "http", + "method": "POST", + "path": "/", + "headers": [], + "query_string": b"", + } + request = Request(scope) + builder = BedrockCallContextBuilder() + result = builder.build(request) + + generated_id = result.state["request_id"] + assert generated_id is not None + # Verify it's a valid UUID + uuid.UUID(generated_id) + assert BedrockAgentCoreContext.get_request_id() == generated_id + + self._run_in_isolated_context(_test) + + def test_optional_headers_omitted_from_state_when_absent(self): + """When optional headers (token, oauth2) are missing, they must not appear in state.""" + + def _test(): + from starlette.requests import Request + + scope = { + "type": "http", + "method": "POST", + "path": "/", + "headers": [ + (b"x-amzn-bedrock-agentcore-runtime-session-id", b"sess-only"), + ], + "query_string": b"", + } + request = Request(scope) + builder = BedrockCallContextBuilder() + result = builder.build(request) + + assert result.state["session_id"] == "sess-only" + assert "workload_access_token" not in result.state + assert "oauth2_callback_url" not in result.state + + self._run_in_isolated_context(_test) + + def test_headers_reach_executor_through_full_app(self): + """End-to-end: HTTP headers -> CallContextBuilder -> a2a-sdk -> executor.call_context.""" + + def _test(): + executor = _EchoExecutor() + builder = BedrockCallContextBuilder() + app = build_a2a_app(executor, _make_agent_card(), context_builder=builder) + client = TestClient(app, raise_server_exceptions=False) + + client.post( + "/", + json=_jsonrpc_request("message/send", _send_message_params("ctx-test")), + headers={ + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "unit-sess", + "X-Amzn-Bedrock-AgentCore-Runtime-Request-Id": "unit-req", + "WorkloadAccessToken": "unit-token", + }, + ) + + ctx = executor.last_call_context + assert ctx is not None + assert ctx.state["session_id"] == "unit-sess" + assert ctx.state["request_id"] == "unit-req" + assert ctx.state["workload_access_token"] == "unit-token" + + self._run_in_isolated_context(_test) + + +class TestServeA2A: + @patch("uvicorn.run") + def test_default_localhost(self, mock_uvicorn_run): + with patch.dict("os.environ", {}, clear=False): + with patch("os.path.exists", return_value=False): + serve_a2a(_EchoExecutor(), _make_agent_card()) + kw = mock_uvicorn_run.call_args[1] + assert kw["host"] == "127.0.0.1" + assert kw["port"] == 9000 + + @patch("uvicorn.run") + def test_docker_detection_dockerenv(self, mock_uvicorn_run): + with patch("os.path.exists", return_value=True): + serve_a2a(_EchoExecutor(), _make_agent_card()) + assert mock_uvicorn_run.call_args[1]["host"] == "0.0.0.0" + + @patch("uvicorn.run") + def test_docker_detection_env_var(self, mock_uvicorn_run): + with patch.dict("os.environ", {"DOCKER_CONTAINER": "true"}): + with patch("os.path.exists", return_value=False): + serve_a2a(_EchoExecutor(), _make_agent_card()) + assert mock_uvicorn_run.call_args[1]["host"] == "0.0.0.0" + + @patch("uvicorn.run") + def test_custom_host_port(self, mock_uvicorn_run): + serve_a2a(_EchoExecutor(), _make_agent_card(), host="10.0.0.1", port=8888) + kw = mock_uvicorn_run.call_args[1] + assert kw["host"] == "10.0.0.1" + assert kw["port"] == 8888 + + @patch("uvicorn.run") + def test_kwargs_passthrough(self, mock_uvicorn_run): + serve_a2a( + _EchoExecutor(), + _make_agent_card(), + host="127.0.0.1", + workers=4, + log_level="debug", + ) + kw = mock_uvicorn_run.call_args[1] + assert kw["workers"] == 4 + assert kw["log_level"] == "debug" + + @patch("uvicorn.run") + def test_serve_without_agent_card(self, mock_uvicorn_run): + """serve_a2a works with just an executor, no card needed.""" + with patch("os.path.exists", return_value=False): + serve_a2a(_EchoExecutor()) + mock_uvicorn_run.assert_called_once() diff --git a/tests/integration/runtime/test_a2a_integration.py b/tests/integration/runtime/test_a2a_integration.py new file mode 100644 index 00000000..fbf425e2 --- /dev/null +++ b/tests/integration/runtime/test_a2a_integration.py @@ -0,0 +1,244 @@ +"""Integration tests for A2A protocol support. + +Uses a real A2AStarletteApplication + DefaultRequestHandler with a concrete +executor -- no mocks for the a2a-sdk layer. Every test sends real HTTP +requests through the full stack. +""" + +import json +import uuid + +import pytest +from starlette.testclient import TestClient + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, + Part, + TextPart, + UnsupportedOperationError, +) +from a2a.utils import new_task +from a2a.utils.errors import ServerError +from bedrock_agentcore.runtime.a2a import BedrockCallContextBuilder, build_a2a_app + + +class EchoExecutor(AgentExecutor): + """Echoes user input back as an artifact. Records context for inspection.""" + + def __init__(self): + self.last_call_context = None + self.last_user_text = None + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + self.last_call_context = context.call_context + task = context.current_task or new_task(context.message) + if not context.current_task: + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + + user_text = context.get_user_input() + self.last_user_text = user_text + + await updater.add_artifact([Part(root=TextPart(text=f"echo: {user_text}"))]) + await updater.complete() + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + raise ServerError(error=UnsupportedOperationError()) + + +def _make_card() -> AgentCard: + return AgentCard( + name="echo-agent", + description="Integration test echo agent", + url="http://localhost:9000", + version="0.1.0", + capabilities=AgentCapabilities(streaming=True), + skills=[AgentSkill(id="echo", name="echo", description="Echoes input", tags=["echo"])], + default_input_modes=["text"], + default_output_modes=["text"], + ) + + +def _jsonrpc_request(method: str, params: dict | None = None, req_id: int = 1) -> dict: + body: dict = {"jsonrpc": "2.0", "method": method, "id": req_id} + if params is not None: + body["params"] = params + return body + + +def _send_message_params(text: str = "hello") -> dict: + return { + "message": { + "message_id": str(uuid.uuid4()), + "role": "user", + "parts": [{"kind": "text", "text": text}], + } + } + + +@pytest.fixture() +def echo_executor(): + return EchoExecutor() + + +@pytest.fixture() +def a2a_client(echo_executor): + """Full app with BedrockCallContextBuilder wired in -- exercises our glue.""" + app = build_a2a_app(echo_executor, _make_card(), context_builder=BedrockCallContextBuilder()) + return TestClient(app, raise_server_exceptions=False) + + +@pytest.mark.integration +class TestA2AServerIntegration: + def test_message_send_returns_completed_task_with_echo_artifact(self, a2a_client): + resp = a2a_client.post( + "/", + json=_jsonrpc_request("message/send", _send_message_params("hi")), + ) + assert resp.status_code == 200 + body = resp.json() + assert "result" in body + task = body["result"] + assert task["status"]["state"] == "completed" + artifacts = task["artifacts"] + assert len(artifacts) == 1 + assert artifacts[0]["parts"][0]["text"] == "echo: hi" + + def test_message_send_stream_produces_sse_with_artifact_and_status(self, a2a_client): + resp = a2a_client.post( + "/", + json=_jsonrpc_request("message/stream", _send_message_params("stream-test")), + ) + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers.get("content-type", "") + + # Parse SSE data lines — each is a JSON-RPC envelope with result.kind + results = [] + for line in resp.text.split("\n"): + if line.startswith("data:"): + data_str = line[len("data:") :].strip() + if data_str: + envelope = json.loads(data_str) + result = envelope.get("result", {}) + results.append(result) + + assert len(results) >= 2, f"Expected at least 2 SSE events, got {len(results)}" + + kinds = [r.get("kind") for r in results] + assert "artifact-update" in kinds, f"No artifact-update event in: {kinds}" + assert "status-update" in kinds, f"No status-update event in: {kinds}" + + # Verify the artifact content in the artifact-update event + artifact_event = next(r for r in results if r.get("kind") == "artifact-update") + assert artifact_event["artifact"]["parts"][0]["text"] == "echo: stream-test" + + # Verify the final status is completed + status_event = next(r for r in results if r.get("kind") == "status-update") + assert status_event["status"]["state"] == "completed" + + def test_get_task_returns_previously_created_task(self, a2a_client): + send_resp = a2a_client.post( + "/", + json=_jsonrpc_request("message/send", _send_message_params("for-get")), + ) + task_id = send_resp.json()["result"]["id"] + + resp = a2a_client.post( + "/", + json=_jsonrpc_request("tasks/get", {"id": task_id}), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["result"]["id"] == task_id + assert body["result"]["status"]["state"] == "completed" + assert body["result"]["artifacts"][0]["parts"][0]["text"] == "echo: for-get" + + def test_cancel_task_returns_unsupported_error(self, a2a_client): + send_resp = a2a_client.post( + "/", + json=_jsonrpc_request("message/send", _send_message_params("for-cancel")), + ) + task_id = send_resp.json()["result"]["id"] + + resp = a2a_client.post( + "/", + json=_jsonrpc_request("tasks/cancel", {"id": task_id}), + ) + assert resp.status_code == 200 + body = resp.json() + assert "error" in body + + def test_unknown_method_returns_method_not_found(self, a2a_client): + resp = a2a_client.post( + "/", + json=_jsonrpc_request("nonexistent/method"), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["error"]["code"] == -32601 + + def test_invalid_params_returns_error(self, a2a_client): + resp = a2a_client.post( + "/", + json=_jsonrpc_request("message/send", {"bad_key": "bad_value"}), + ) + assert resp.status_code == 200 + body = resp.json() + assert "error" in body + + def test_agent_card_endpoint_returns_full_card(self, a2a_client): + resp = a2a_client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "echo-agent" + assert body["version"] == "0.1.0" + assert len(body["skills"]) == 1 + assert body["skills"][0]["id"] == "echo" + assert body["capabilities"]["streaming"] is True + + def test_ping_endpoint_returns_bedrock_health_format(self, a2a_client): + resp = a2a_client.get("/ping") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "Healthy" + assert isinstance(body["time_of_last_update"], int) + + def test_bedrock_headers_propagated_to_executor(self, echo_executor): + builder = BedrockCallContextBuilder() + app = build_a2a_app(echo_executor, _make_card(), context_builder=builder) + client = TestClient(app, raise_server_exceptions=False) + + resp = client.post( + "/", + json=_jsonrpc_request("message/send", _send_message_params("headers-test")), + headers={ + "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "integ-sess-1", + "X-Amzn-Bedrock-AgentCore-Runtime-Request-Id": "integ-req-1", + "WorkloadAccessToken": "integ-token", + "OAuth2CallbackUrl": "https://callback.example.com", + }, + ) + assert resp.status_code == 200 + # Verify the task completed (executor actually ran) + assert resp.json()["result"]["status"]["state"] == "completed" + + # Verify Bedrock headers reached the executor via ServerCallContext + ctx = echo_executor.last_call_context + assert ctx is not None + assert ctx.state["session_id"] == "integ-sess-1" + assert ctx.state["request_id"] == "integ-req-1" + assert ctx.state["workload_access_token"] == "integ-token" + assert ctx.state["oauth2_callback_url"] == "https://callback.example.com" + + def test_user_input_reaches_executor(self, a2a_client, echo_executor): + """Verify the user message text flows all the way to the executor.""" + a2a_client.post( + "/", + json=_jsonrpc_request("message/send", _send_message_params("verify-input")), + ) + assert echo_executor.last_user_text == "verify-input" From 3bcc879ffc3374f59f967ff29feb6eed1f2ca798 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 16 Mar 2026 17:20:47 -0400 Subject: [PATCH 2/9] chore: update uv.lock for a2a optional dependency --- uv.lock | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index d19a3fdf..c5c389c6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,33 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "a2a-sdk" +version = "0.3.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/83/3c99b276d09656cce039464509f05bf385e5600d6dc046a131bbcf686930/a2a_sdk-0.3.25.tar.gz", hash = "sha256:afda85bab8d6af0c5d15e82f326c94190f6be8a901ce562d045a338b7127242f", size = 270638, upload-time = "2026-03-10T13:08:46.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f9/6a62520b7ecb945188a6e1192275f4732ff9341cd4629bc975a6c146aeab/a2a_sdk-0.3.25-py3-none-any.whl", hash = "sha256:2fce38faea82eb0b6f9f9c2bcf761b0d78612c80ef0e599b50d566db1b2654b5", size = 149609, upload-time = "2026-03-10T13:08:44.7Z" }, +] + +[package.optional-dependencies] +http-server = [ + { name = "fastapi" }, + { name = "sse-starlette" }, + { name = "starlette" }, +] [[package]] name = "aiohappyeyeballs" @@ -144,6 +171,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -236,6 +272,9 @@ dependencies = [ ] [package.optional-dependencies] +a2a = [ + { name = "a2a-sdk", extra = ["http-server"] }, +] strands-agents = [ { name = "strands-agents" }, ] @@ -245,6 +284,7 @@ strands-agents-evals = [ [package.dev-dependencies] dev = [ + { name = "a2a-sdk", extra = ["http-server"] }, { name = "httpx" }, { name = "moto" }, { name = "mypy" }, @@ -262,6 +302,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a'", specifier = ">=0.3" }, { name = "boto3", specifier = ">=1.42.63" }, { name = "botocore", specifier = ">=1.42.63" }, { name = "pydantic", specifier = ">=2.0.0,<2.41.3" }, @@ -273,10 +314,11 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.34.2" }, { name = "websockets", specifier = ">=12.0" }, ] -provides-extras = ["strands-agents", "strands-agents-evals"] +provides-extras = ["a2a", "strands-agents", "strands-agents-evals"] [package.metadata.requires-dev] dev = [ + { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "moto", specifier = ">=5.1.6" }, { name = "mypy", specifier = ">=1.16.1" }, @@ -670,6 +712,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + [[package]] name = "filelock" version = "3.20.3" @@ -800,6 +858,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1619,6 +1718,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -2353,14 +2500,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] From 1e15b1ef1543b4bf094767a1cfb8c1b11e8bc35f Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 16 Mar 2026 17:27:26 -0400 Subject: [PATCH 3/9] fix: sort a2a/starlette imports for ruff 0.12.0 compatibility --- src/bedrock_agentcore/runtime/a2a.py | 5 ++--- tests/bedrock_agentcore/runtime/test_a2a.py | 4 ++-- tests/integration/runtime/test_a2a_integration.py | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index 4aa66348..fcc1e9e1 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -152,12 +152,11 @@ def build_a2a_app( _check_a2a_sdk() - from starlette.responses import JSONResponse - from starlette.routing import Route - from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore + from starlette.responses import JSONResponse + from starlette.routing import Route runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, "http://localhost:9000/") diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index a2a16a41..46ff8dff 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -2,13 +2,13 @@ import uuid from unittest.mock import patch -from starlette.testclient import TestClient - from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import InMemoryTaskStore, TaskUpdater from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart from a2a.utils import new_task +from starlette.testclient import TestClient + from bedrock_agentcore.runtime.a2a import ( BedrockCallContextBuilder, build_a2a_app, diff --git a/tests/integration/runtime/test_a2a_integration.py b/tests/integration/runtime/test_a2a_integration.py index fbf425e2..e7ea5eab 100644 --- a/tests/integration/runtime/test_a2a_integration.py +++ b/tests/integration/runtime/test_a2a_integration.py @@ -9,8 +9,6 @@ import uuid import pytest -from starlette.testclient import TestClient - from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater @@ -24,6 +22,8 @@ ) from a2a.utils import new_task from a2a.utils.errors import ServerError +from starlette.testclient import TestClient + from bedrock_agentcore.runtime.a2a import BedrockCallContextBuilder, build_a2a_app From 8588bb9f13d8525fa258af1b414fd33512d273d3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 17 Mar 2026 10:46:35 -0400 Subject: [PATCH 4/9] refactor: build Starlette app with /ping upfront instead of mutating routes Create the Starlette app with the /ping route included in the constructor, then use add_routes_to_app() to wire A2A endpoints. This avoids depending on route mutation after build(). --- src/bedrock_agentcore/runtime/a2a.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index fcc1e9e1..3a9c4004 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -155,6 +155,7 @@ def build_a2a_app( from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore + from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route @@ -181,8 +182,6 @@ def build_a2a_app( context_builder=context_builder, ) - app = a2a_app.build() - last_status_update_time = time.time() def _handle_ping(request: Any) -> JSONResponse: @@ -198,7 +197,10 @@ def _handle_ping(request: Any) -> JSONResponse: status = PingStatus.HEALTHY return JSONResponse({"status": status.value, "time_of_last_update": int(last_status_update_time)}) - app.routes.append(Route("/ping", _handle_ping, methods=["GET"])) + # Build the Starlette app with /ping included upfront, then add A2A routes, + # so we don't depend on mutating app.routes after build(). + app = Starlette(routes=[Route("/ping", _handle_ping, methods=["GET"])]) + a2a_app.add_routes_to_app(app) return app From 1892db07c8342cfe9d8e3286ec9378c19ea9b038 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 17 Mar 2026 14:21:10 -0400 Subject: [PATCH 5/9] feat: add build_runtime_url utility for ARN-to-URL conversion Adds build_runtime_url(agent_arn, region=None) that constructs the Bedrock AgentCore runtime invocation URL from an agent ARN, properly URL-encoding the ARN. Extracts region from the ARN if not provided. --- src/bedrock_agentcore/runtime/__init__.py | 3 +- src/bedrock_agentcore/runtime/a2a.py | 32 +++++++++++++++++++-- tests/bedrock_agentcore/runtime/test_a2a.py | 27 +++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/bedrock_agentcore/runtime/__init__.py b/src/bedrock_agentcore/runtime/__init__.py index ee849981..07aa53df 100644 --- a/src/bedrock_agentcore/runtime/__init__.py +++ b/src/bedrock_agentcore/runtime/__init__.py @@ -6,7 +6,7 @@ - BedrockAgentCoreContext: Agent identity context """ -from .a2a import BedrockCallContextBuilder, build_a2a_app, serve_a2a +from .a2a import BedrockCallContextBuilder, build_a2a_app, build_runtime_url, serve_a2a from .agent_core_runtime_client import AgentCoreRuntimeClient from .app import BedrockAgentCoreApp from .context import BedrockAgentCoreContext, RequestContext @@ -20,5 +20,6 @@ "BedrockAgentCoreContext", "PingStatus", "build_a2a_app", + "build_runtime_url", "serve_a2a", ] diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index 3a9c4004..6fdee9f1 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -62,6 +62,31 @@ def _build_agent_card(executor: Any, url: str) -> Any: ) +def build_runtime_url(agent_arn: str, region: Optional[str] = None) -> str: + """Build the Bedrock AgentCore runtime invocation URL from an agent ARN. + + Args: + agent_arn: The agent runtime ARN, e.g. + ``arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my-agent-abc123``. + region: AWS region override. If ``None``, extracted from the ARN. + + Returns: + The full invocation URL with the ARN properly URL-encoded. + """ + from urllib.parse import quote + + if region is None: + # ARN format: arn:aws:bedrock-agentcore:::runtime/ + parts = agent_arn.split(":") + if len(parts) >= 4: + region = parts[3] + else: + raise ValueError(f"Cannot extract region from ARN: {agent_arn}") + + encoded_arn = quote(agent_arn, safe="") + return f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encoded_arn}/invocations" + + class BedrockCallContextBuilder: """Extracts Bedrock runtime headers and propagates them into BedrockAgentCoreContext. @@ -152,13 +177,14 @@ def build_a2a_app( _check_a2a_sdk() - from a2a.server.apps import A2AStarletteApplication - from a2a.server.request_handlers import DefaultRequestHandler - from a2a.server.tasks import InMemoryTaskStore from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route + from a2a.server.apps import A2AStarletteApplication + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.tasks import InMemoryTaskStore + runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, "http://localhost:9000/") if agent_card is None: diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index 46ff8dff..85ee2f4b 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -2,16 +2,17 @@ import uuid from unittest.mock import patch +from starlette.testclient import TestClient + from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import InMemoryTaskStore, TaskUpdater from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart from a2a.utils import new_task -from starlette.testclient import TestClient - from bedrock_agentcore.runtime.a2a import ( BedrockCallContextBuilder, build_a2a_app, + build_runtime_url, serve_a2a, ) from bedrock_agentcore.runtime.context import BedrockAgentCoreContext @@ -371,3 +372,25 @@ def test_serve_without_agent_card(self, mock_uvicorn_run): with patch("os.path.exists", return_value=False): serve_a2a(_EchoExecutor()) mock_uvicorn_run.assert_called_once() + + +class TestBuildRuntimeUrl: + def test_builds_url_from_arn(self): + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my-agent-abc123" + url = build_runtime_url(arn) + assert url == ( + "https://bedrock-agentcore.us-east-1.amazonaws.com" + "/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aruntime%2Fmy-agent-abc123" + "/invocations" + ) + + def test_region_override(self): + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my-agent" + url = build_runtime_url(arn, region="eu-west-1") + assert "bedrock-agentcore.eu-west-1.amazonaws.com" in url + + def test_invalid_arn_raises(self): + import pytest + + with pytest.raises(ValueError, match="Cannot extract region"): + build_runtime_url("not-an-arn") From bc428f9a6a54dfdbfba00d1690c965d952decb03 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 17 Mar 2026 14:25:02 -0400 Subject: [PATCH 6/9] refactor: lazy-import A2A symbols so a2a-sdk is not required at import time --- src/bedrock_agentcore/runtime/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/bedrock_agentcore/runtime/__init__.py b/src/bedrock_agentcore/runtime/__init__.py index 07aa53df..03041a50 100644 --- a/src/bedrock_agentcore/runtime/__init__.py +++ b/src/bedrock_agentcore/runtime/__init__.py @@ -6,7 +6,6 @@ - BedrockAgentCoreContext: Agent identity context """ -from .a2a import BedrockCallContextBuilder, build_a2a_app, build_runtime_url, serve_a2a from .agent_core_runtime_client import AgentCoreRuntimeClient from .app import BedrockAgentCoreApp from .context import BedrockAgentCoreContext, RequestContext @@ -23,3 +22,13 @@ "build_runtime_url", "serve_a2a", ] + + +def __getattr__(name: str): + """Lazy imports for A2A symbols so the a2a-sdk optional dependency is not required at import time.""" + _a2a_exports = {"BedrockCallContextBuilder", "build_a2a_app", "build_runtime_url", "serve_a2a"} + if name in _a2a_exports: + from . import a2a as _a2a_module + + return getattr(_a2a_module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From 1acf86a7ecee07fa57e2b220b6919d59cb4726bb Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 17 Mar 2026 14:27:24 -0400 Subject: [PATCH 7/9] docs: add A2A protocol documentation and README section --- README.md | 18 +++ docs/examples/a2a_protocol_examples.md | 194 +++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 docs/examples/a2a_protocol_examples.md diff --git a/README.md b/README.md index 13fe2f09..58103853 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,24 @@ app.run() # Ready to run on Bedrock AgentCore **Production:** [AWS CDK](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_bedrockagentcore-readme.html). +## A2A Protocol Support + +Serve your agent using the [A2A (Agent-to-Agent) protocol](https://google.github.io/A2A/) on Bedrock AgentCore Runtime. Works with any framework that provides an a2a-sdk `AgentExecutor` (Strands, LangGraph, Google ADK, or custom). + +```bash +pip install "bedrock-agentcore[a2a]" +``` + +```python +from strands import Agent +from strands.a2a import StrandsA2AExecutor +from bedrock_agentcore.runtime import serve_a2a + +agent = Agent(model="us.anthropic.claude-sonnet-4-20250514", system_prompt="You are a helpful assistant.") +serve_a2a(StrandsA2AExecutor(agent)) +``` + +See [A2A Protocol Examples](docs/examples/a2a_protocol_examples.md) for LangGraph, Google ADK, and advanced usage. ## 📝 License & Contributing diff --git a/docs/examples/a2a_protocol_examples.md b/docs/examples/a2a_protocol_examples.md new file mode 100644 index 00000000..965d14e6 --- /dev/null +++ b/docs/examples/a2a_protocol_examples.md @@ -0,0 +1,194 @@ +# A2A Protocol Support + +This document explains how to serve your agent using the [A2A (Agent-to-Agent) protocol](https://google.github.io/A2A/) on Bedrock AgentCore Runtime. + +## Installation + +A2A support requires the optional `a2a` extra: + +```bash +pip install "bedrock-agentcore[a2a]" +``` + +## Quick Start + +### Strands Agent + +Strands provides a built-in `StrandsA2AExecutor` that wraps a Strands `Agent` as an A2A executor. When no `AgentCard` is provided, one is auto-built from the agent's `name` and `description`. + +```python +from strands import Agent +from strands.a2a import StrandsA2AExecutor +from bedrock_agentcore.runtime import serve_a2a + +agent = Agent( + model="us.anthropic.claude-sonnet-4-20250514", + system_prompt="You are a helpful calculator.", +) + +if __name__ == "__main__": + serve_a2a(StrandsA2AExecutor(agent)) +``` + +### LangGraph Agent + +LangGraph requires a thin `AgentExecutor` wrapper (~15 lines) and an explicit `AgentCard`: + +```python +from langchain_aws import ChatBedrockConverse +from langgraph.prebuilt import create_react_agent + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart +from a2a.utils import new_task +from bedrock_agentcore.runtime import serve_a2a + +llm = ChatBedrockConverse(model="us.anthropic.claude-sonnet-4-20250514") +graph = create_react_agent(llm, tools=[], prompt="You are a helpful calculator.") + + +class LangGraphA2AExecutor(AgentExecutor): + def __init__(self, graph): + self.graph = graph + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task = context.current_task or new_task(context.message) + if not context.current_task: + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + user_text = context.get_user_input() + result = await self.graph.ainvoke({"messages": [("user", user_text)]}) + response = result["messages"][-1].content + await updater.add_artifact([Part(root=TextPart(text=response))]) + await updater.complete() + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + pass + + +card = AgentCard( + name="langgraph-agent", + description="A LangGraph agent on Bedrock AgentCore", + url="http://localhost:9000/", + version="0.1.0", + capabilities=AgentCapabilities(streaming=True), + skills=[AgentSkill(id="calc", name="calculator", description="Arithmetic", tags=["math"])], + default_input_modes=["text"], + default_output_modes=["text"], +) + +if __name__ == "__main__": + serve_a2a(LangGraphA2AExecutor(graph), card) +``` + +### Google ADK Agent + +Google ADK provides `A2aAgentExecutor` built-in. You supply an explicit `AgentCard`: + +```python +from google.adk.agents import LlmAgent +from google.adk.runners import Runner +from google.adk.a2a import A2aAgentExecutor + +from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from bedrock_agentcore.runtime import serve_a2a + +agent = LlmAgent( + model="gemini-2.0-flash", + name="calculator", + description="A calculator agent", + instruction="You are a helpful calculator.", +) +runner = Runner(agent=agent, app_name="calculator", session_service=None) + +card = AgentCard( + name="adk-agent", + description="A Google ADK agent on Bedrock AgentCore", + url="http://localhost:9000/", + version="0.1.0", + capabilities=AgentCapabilities(streaming=True), + skills=[AgentSkill(id="calc", name="calculator", description="Arithmetic", tags=["math"])], + default_input_modes=["text"], + default_output_modes=["text"], +) + +if __name__ == "__main__": + serve_a2a(A2aAgentExecutor(runner=runner), card) +``` + +## API Reference + +### `serve_a2a(executor, agent_card=None, *, port=9000, host=None, ...)` + +Starts a Bedrock-compatible A2A server with `uvicorn`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `executor` | `AgentExecutor` | required | An a2a-sdk `AgentExecutor` that implements the agent logic | +| `agent_card` | `AgentCard` | `None` | Agent metadata. Auto-built from executor if omitted (works best with Strands) | +| `port` | `int` | `9000` | Port to serve on | +| `host` | `str` | `None` | Host to bind to. Auto-detected: `0.0.0.0` in Docker, `127.0.0.1` otherwise | +| `task_store` | `TaskStore` | `None` | Custom task store; defaults to `InMemoryTaskStore` | +| `context_builder` | `CallContextBuilder` | `None` | Custom context builder; defaults to `BedrockCallContextBuilder` | +| `ping_handler` | `Callable[[], PingStatus]` | `None` | Custom health check callback | +| `**kwargs` | | | Additional arguments forwarded to `uvicorn.run()` | + +### `build_a2a_app(executor, agent_card=None, *, task_store=None, context_builder=None, ping_handler=None)` + +Builds a Starlette ASGI application without starting a server. Useful for testing or embedding in a larger app. + +Returns a `Starlette` application with routes: +- `POST /` — A2A JSON-RPC endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) +- `GET /.well-known/agent-card.json` — Agent card discovery +- `GET /ping` — Bedrock health check + +### `build_runtime_url(agent_arn, region=None)` + +Builds the Bedrock AgentCore runtime invocation URL from an agent ARN. + +```python +from bedrock_agentcore.runtime import build_runtime_url + +url = build_runtime_url("arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my-agent-abc123") +# https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3A.../invocations +``` + +### `BedrockCallContextBuilder` + +Extracts Bedrock runtime headers from incoming requests and propagates them into `BedrockAgentCoreContext` contextvars. This is the default `context_builder` used by `build_a2a_app` and `serve_a2a`. + +Headers extracted: +- `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` — session ID +- `X-Amzn-Bedrock-AgentCore-Runtime-Request-Id` — request ID (auto-generated UUID if missing) +- `WorkloadAccessToken` — workload access token +- `OAuth2CallbackUrl` — OAuth2 callback URL +- `Authorization` — authorization header +- `X-Amzn-Bedrock-AgentCore-Runtime-Custom-*` — custom headers + +## Behavior Details + +### Agent Card Auto-Population + +When deployed on Bedrock AgentCore, the `AGENTCORE_RUNTIME_URL` environment variable is set automatically. The agent card's `url` field is updated to match, so you don't need to hardcode the deployed URL. + +### Docker Host Detection + +When `host` is not specified, `serve_a2a` automatically binds to `0.0.0.0` inside Docker containers (detected via `/.dockerenv` or `DOCKER_CONTAINER` env var) and `127.0.0.1` otherwise. + +### Custom Ping Handler + +```python +from bedrock_agentcore.runtime import serve_a2a +from bedrock_agentcore.runtime.models import PingStatus + +def my_ping(): + if is_overloaded(): + return PingStatus.HEALTHY_BUSY + return PingStatus.HEALTHY + +serve_a2a(executor, ping_handler=my_ping) +``` + +If the ping handler raises an exception, the server falls back to `PingStatus.HEALTHY`. From a06ada6797c632c1f3eb7b502bd18d20acafe573 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 17 Mar 2026 14:31:33 -0400 Subject: [PATCH 8/9] fix: sort integration test imports for ruff 0.12.0 compatibility --- tests/integration/runtime/test_a2a_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/runtime/test_a2a_integration.py b/tests/integration/runtime/test_a2a_integration.py index e7ea5eab..fbf425e2 100644 --- a/tests/integration/runtime/test_a2a_integration.py +++ b/tests/integration/runtime/test_a2a_integration.py @@ -9,6 +9,8 @@ import uuid import pytest +from starlette.testclient import TestClient + from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater @@ -22,8 +24,6 @@ ) from a2a.utils import new_task from a2a.utils.errors import ServerError -from starlette.testclient import TestClient - from bedrock_agentcore.runtime.a2a import BedrockCallContextBuilder, build_a2a_app From 46e55db929a3c8ce08351b6b1901a4bc6cfe3cb8 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 17 Mar 2026 14:38:44 -0400 Subject: [PATCH 9/9] fix: add known-third-party config for a2a and fix import ordering Adds [tool.ruff.lint.isort] known-third-party = ["a2a"] so ruff classifies a2a imports consistently regardless of whether a2a-sdk is installed in the lint environment. --- pyproject.toml | 3 +++ src/bedrock_agentcore/runtime/a2a.py | 7 +++---- tests/bedrock_agentcore/runtime/test_a2a.py | 4 ++-- tests/integration/runtime/test_a2a_integration.py | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d1d1e72f..e7530ae9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,9 @@ select = [ "!src/**/*.py" = ["D"] "src/bedrock_agentcore/memory/metadata-workflow.ipynb" = ["E501"] +[tool.ruff.lint.isort] +known-third-party = ["a2a"] + [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index 6fdee9f1..0cbfe60f 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -177,13 +177,12 @@ def build_a2a_app( _check_a2a_sdk() - from starlette.applications import Starlette - from starlette.responses import JSONResponse - from starlette.routing import Route - from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, "http://localhost:9000/") diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index 85ee2f4b..b789dd46 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -2,13 +2,13 @@ import uuid from unittest.mock import patch -from starlette.testclient import TestClient - from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import InMemoryTaskStore, TaskUpdater from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart from a2a.utils import new_task +from starlette.testclient import TestClient + from bedrock_agentcore.runtime.a2a import ( BedrockCallContextBuilder, build_a2a_app, diff --git a/tests/integration/runtime/test_a2a_integration.py b/tests/integration/runtime/test_a2a_integration.py index fbf425e2..e7ea5eab 100644 --- a/tests/integration/runtime/test_a2a_integration.py +++ b/tests/integration/runtime/test_a2a_integration.py @@ -9,8 +9,6 @@ import uuid import pytest -from starlette.testclient import TestClient - from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater @@ -24,6 +22,8 @@ ) from a2a.utils import new_task from a2a.utils.errors import ServerError +from starlette.testclient import TestClient + from bedrock_agentcore.runtime.a2a import BedrockCallContextBuilder, build_a2a_app