diff --git a/docs/examples/a2a_protocol_examples.md b/docs/examples/a2a_protocol_examples.md index 069620d6..3fa0c7a7 100644 --- a/docs/examples/a2a_protocol_examples.md +++ b/docs/examples/a2a_protocol_examples.md @@ -137,7 +137,7 @@ Starts a Bedrock-compatible A2A server with `uvicorn`. |-----------|------|---------|-------------| | `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 \| None` | `None` | Port to serve on. Uses `PORT`, then `9000`, when omitted | +| `port` | `int \| None` | `None` | Port to serve on. Uses `A2A_PORT`, then `9000`, when omitted. The generic `PORT` is ignored — see [Ports](#ports) | | `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` | @@ -178,6 +178,29 @@ Headers extracted: ## Behavior Details +### Ports + +The AgentCore Runtime service contract fixes the A2A container port at **9000** (HTTP uses 8080, MCP uses 8000). The +runtime proxies invocations to 9000 only, so an A2A server bound elsewhere is unreachable and every invocation fails +with **HTTP 424** (`RuntimeClientError`). + +`serve_a2a` therefore defaults to 9000 and **ignores the generic `PORT` environment variable**. `PORT` is a widespread +convention (Heroku, Cloud Run, App Runner) and is commonly already set to another protocol's port — notably in images +shared across an HTTP and an A2A runtime, where `PORT=8080` is correct for HTTP and fatal for A2A. + +To override the port for local development, use the protocol-scoped `A2A_PORT`, or pass `port=` explicitly: + +```bash +A2A_PORT=9001 python main.py +``` + +```python +serve_a2a(executor, port=9001) +``` + +Precedence is `port=` argument, then `A2A_PORT`, then 9000. When the resolved port is not 9000, `serve_a2a` logs a +warning, since that configuration cannot work in a deployed runtime. + ### 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. diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index ad6a4204..720e0791 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -28,6 +28,12 @@ logger = logging.getLogger(__name__) +# The AgentCore Runtime A2A service contract fixes the container port at 9000 +# (HTTP is 8080, MCP is 8000). It is not negotiable, so it is only overridable +# via an explicit ``port=`` argument or the protocol-scoped env var below. +A2A_CONTRACT_PORT = 9000 +A2A_PORT_ENV = "A2A_PORT" + def _check_a2a_sdk() -> None: """Raise ImportError with install instructions if a2a-sdk is missing.""" @@ -275,7 +281,7 @@ def build_a2a_app( from starlette.routing import Route runtime_url_override = os.environ.get(AGENTCORE_RUNTIME_URL_ENV) or runtime_url - advertised_url = runtime_url_override or "http://localhost:9000/" + advertised_url = runtime_url_override or f"http://localhost:{A2A_CONTRACT_PORT}/" is_a2a_v1 = _is_a2a_v1() if agent_card is None: @@ -354,8 +360,11 @@ def serve_a2a( 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. Defaults to the ``PORT`` environment variable, - or 9000 when it is unset. + port: Port to serve on. Defaults to the ``A2A_PORT`` environment + variable, or 9000 when it is unset. ``PORT`` is deliberately not + consulted: the AgentCore A2A contract fixes the port at 9000, and + ``PORT`` is widely set to another protocol's port (8080 for HTTP, + 8000 for MCP) in images shared across runtimes. host: Host to bind to; auto-detected if ``None``. task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``. context_builder: Optional ``ServerCallContextBuilder``; defaults to @@ -367,7 +376,17 @@ def serve_a2a( import uvicorn - resolved_port = port if port is not None else int(os.environ.get("PORT", "9000")) + resolved_port = port if port is not None else int(os.environ.get(A2A_PORT_ENV, A2A_CONTRACT_PORT)) + + if resolved_port != A2A_CONTRACT_PORT: + logger.warning( + "A2A server binding port %d, but the AgentCore Runtime A2A service contract " + "requires %d. Deployed invocations will fail with HTTP 424 (RuntimeClientError) " + "because the runtime proxies to %d only.", + resolved_port, + A2A_CONTRACT_PORT, + A2A_CONTRACT_PORT, + ) app = build_a2a_app( executor, diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index dd3c0062..3bdc04a2 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -1,4 +1,5 @@ import contextvars +import logging import uuid from unittest.mock import patch @@ -456,7 +457,7 @@ def test_default_localhost(self, mock_uvicorn_run): with patch.dict("os.environ", {}, clear=False): import os - os.environ.pop("PORT", None) + os.environ.pop("A2A_PORT", None) with patch("os.path.exists", return_value=False): serve_a2a(_EchoExecutor(), _make_agent_card()) kw = mock_uvicorn_run.call_args[1] @@ -465,7 +466,7 @@ def test_default_localhost(self, mock_uvicorn_run): @patch("uvicorn.run") def test_port_from_environment(self, mock_uvicorn_run): - with patch.dict("os.environ", {"PORT": "9001"}, clear=True): + with patch.dict("os.environ", {"A2A_PORT": "9001"}, clear=True): serve_a2a(_EchoExecutor()) app = mock_uvicorn_run.call_args.args[0] @@ -475,7 +476,7 @@ def test_port_from_environment(self, mock_uvicorn_run): @patch("uvicorn.run") def test_port_from_environment_updates_explicit_card(self, mock_uvicorn_run): - with patch.dict("os.environ", {"PORT": "9002"}, clear=True): + with patch.dict("os.environ", {"A2A_PORT": "9002"}, clear=True): serve_a2a(_EchoExecutor(), _make_agent_card()) app = mock_uvicorn_run.call_args.args[0] @@ -485,7 +486,7 @@ def test_port_from_environment_updates_explicit_card(self, mock_uvicorn_run): @patch("uvicorn.run") def test_explicit_port_overrides_environment(self, mock_uvicorn_run): - with patch.dict("os.environ", {"PORT": "9001"}): + with patch.dict("os.environ", {"A2A_PORT": "9001"}): serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888) app = mock_uvicorn_run.call_args.args[0] response = TestClient(app).get("/.well-known/agent-card.json") @@ -497,7 +498,7 @@ def test_runtime_url_environment_overrides_port_for_card(self, mock_uvicorn_run) with patch.dict( "os.environ", { - "PORT": "9002", + "A2A_PORT": "9002", "AGENTCORE_RUNTIME_URL": "https://deployed.example.com/", }, clear=True, @@ -509,6 +510,41 @@ def test_runtime_url_environment_overrides_port_for_card(self, mock_uvicorn_run) assert mock_uvicorn_run.call_args.kwargs["port"] == 9002 assert _card_response_url(response.json()) == "https://deployed.example.com/" + @patch("uvicorn.run") + def test_generic_port_env_var_is_ignored(self, mock_uvicorn_run): + """PORT must not affect the A2A port: shared images set it to 8080 for HTTP.""" + with patch.dict("os.environ", {"PORT": "8080"}, clear=True): + serve_a2a(_EchoExecutor(), _make_agent_card()) + + app = mock_uvicorn_run.call_args.args[0] + response = TestClient(app).get("/.well-known/agent-card.json") + assert mock_uvicorn_run.call_args.kwargs["port"] == 9000 + assert _card_response_url(response.json()) == "http://localhost:9000/" + + @patch("uvicorn.run") + def test_a2a_port_takes_precedence_over_generic_port(self, mock_uvicorn_run): + with patch.dict("os.environ", {"PORT": "8080", "A2A_PORT": "9003"}, clear=True): + serve_a2a(_EchoExecutor(), _make_agent_card()) + + assert mock_uvicorn_run.call_args.kwargs["port"] == 9003 + + @patch("uvicorn.run") + def test_warns_when_binding_non_contract_port(self, mock_uvicorn_run, caplog): + with patch.dict("os.environ", {}, clear=True): + with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.runtime.a2a"): + serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888) + + assert "requires 9000" in caplog.text + assert "424" in caplog.text + + @patch("uvicorn.run") + def test_no_warning_on_contract_port(self, mock_uvicorn_run, caplog): + with patch.dict("os.environ", {}, clear=True): + with caplog.at_level(logging.WARNING, logger="bedrock_agentcore.runtime.a2a"): + serve_a2a(_EchoExecutor(), _make_agent_card()) + + assert caplog.text == "" + @patch("uvicorn.run") def test_docker_detection_dockerenv(self, mock_uvicorn_run): with patch("os.path.exists", return_value=True):