From 71d1f391a8d5c3dc1fe390281b0bc5af37902cdd Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 22 Jul 2026 17:13:32 -0400 Subject: [PATCH 1/3] feat(a2a): migrate runtime integration to SDK v1 --- pyproject.toml | 4 +- src/bedrock_agentcore/runtime/a2a.py | 67 ++++++++++----- tests/bedrock_agentcore/runtime/test_a2a.py | 77 +++++++++++------ .../runtime/test_a2a_integration.py | 82 ++++++++++--------- uv.lock | 81 ++++++++++-------- 5 files changed, 189 insertions(+), 122 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d430d87e..b5990b19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,13 +154,13 @@ dev = [ "langchain>=1.0.0", "langgraph>=1.0.0", "langchain-mcp-adapters>=0.1.0", - "a2a-sdk[http-server]>=0.3,<1.0", + "a2a-sdk[http-server]>=1.0.1,<2.0", "ag-ui-protocol>=0.1.10", "mcp-proxy-for-aws>=0.1.0", ] [project.optional-dependencies] -a2a = ["a2a-sdk[http-server]>=0.3,<1.0"] +a2a = ["a2a-sdk[http-server]>=1.0.1,<2.0"] ag-ui = ["ag-ui-protocol>=0.1.10"] strands-agents = [ "strands-agents>=1.20.0", diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index 0d757301..c4f50599 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -39,12 +39,12 @@ def _check_a2a_sdk() -> None: def _build_agent_card(executor: Any, url: str) -> Any: - """Build an AgentCard by introspecting a StrandsA2AExecutor. + """Build an AgentCard by introspecting an executor. Extracts name/description from ``executor.agent``. Falls back to generic defaults for other executors. """ - from a2a.types import AgentCapabilities, AgentCard, AgentSkill + from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill name = "agent" description = "A Bedrock AgentCore agent" @@ -57,12 +57,36 @@ def _build_agent_card(executor: Any, url: str) -> Any: 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"], + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url=url, + ) + ], + ) + + +def _set_jsonrpc_url(agent_card: Any, url: str) -> None: + """Set the runtime URL on the card's JSON-RPC interface.""" + from a2a.types import AgentInterface + + for interface in agent_card.supported_interfaces: + if interface.protocol_binding == "JSONRPC": + interface.url = url + return + + agent_card.supported_interfaces.append( + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url=url, + ) ) @@ -97,7 +121,7 @@ def build_runtime_url(agent_arn: str, region: Optional[str] = None) -> str: class BedrockCallContextBuilder: """Extracts Bedrock runtime headers and propagates them into BedrockAgentCoreContext. - Implements the a2a-sdk CallContextBuilder ABC so the A2A server + Implements the a2a-sdk ServerCallContextBuilder ABC so the A2A server automatically calls ``build()`` on every incoming request. """ @@ -160,6 +184,8 @@ def build(self, request: Any) -> Any: _ensure_baggage_processor_registered() state = { + "headers": dict(headers), + "bedrock_request_id": request_id, "request_id": request_id, "session_id": session_id, } @@ -174,9 +200,9 @@ def build(self, request: Any) -> Any: # 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 + from a2a.server.routes import ServerCallContextBuilder - CallContextBuilder.register(BedrockCallContextBuilder) + ServerCallContextBuilder.register(BedrockCallContextBuilder) except Exception: # pragma: no cover pass @@ -196,7 +222,7 @@ def build_a2a_app( 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 + context_builder: Optional ``ServerCallContextBuilder``; defaults to ``BedrockCallContextBuilder``. ping_handler: Optional callback returning a ``PingStatus``. @@ -207,8 +233,8 @@ def build_a2a_app( _check_a2a_sdk() - from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.server.tasks import InMemoryTaskStore from starlette.applications import Starlette from starlette.responses import JSONResponse @@ -219,7 +245,7 @@ def build_a2a_app( 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 + _set_jsonrpc_url(agent_card, runtime_url) if task_store is None: task_store = InMemoryTaskStore() @@ -229,12 +255,17 @@ def build_a2a_app( http_handler = DefaultRequestHandler( agent_executor=executor, task_store=task_store, + agent_card=agent_card, ) - a2a_app = A2AStarletteApplication( - agent_card=agent_card, - http_handler=http_handler, - context_builder=context_builder, + routes = create_agent_card_routes(agent_card) + routes.extend( + create_jsonrpc_routes( + request_handler=http_handler, + rpc_url="/", + context_builder=context_builder, + enable_v0_3_compat=True, + ) ) def _handle_ping(request: Any) -> JSONResponse: @@ -248,12 +279,8 @@ def _handle_ping(request: Any) -> JSONResponse: status = PingStatus.HEALTHY return JSONResponse({"status": status.value}) - # 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 + routes.insert(0, Route("/ping", _handle_ping, methods=["GET"])) + return Starlette(routes=routes) def serve_a2a( @@ -276,7 +303,7 @@ def serve_a2a( 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 + context_builder: Optional ``ServerCallContextBuilder``; defaults to ``BedrockCallContextBuilder``. ping_handler: Optional callback returning a ``PingStatus``. **kwargs: Additional arguments forwarded to ``uvicorn.run()``. diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index c1704ae0..5cd6fcf5 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -2,11 +2,11 @@ import uuid from unittest.mock import patch +from a2a.helpers import new_task_from_user_message 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 a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Part from starlette.testclient import TestClient from bedrock_agentcore.runtime.a2a import ( @@ -27,12 +27,12 @@ def __init__(self): 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) + task = context.current_task or new_task_from_user_message(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.add_artifact([Part(text=f"echo: {user_text}")]) await updater.complete() async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: @@ -43,12 +43,18 @@ 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"], + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url="http://localhost:9000", + ) + ], ) @@ -62,9 +68,9 @@ def _jsonrpc_request(method: str, params: dict | None = None) -> dict: def _send_message_params(text: str = "hello") -> dict: return { "message": { - "message_id": str(uuid.uuid4()), - "role": "user", - "parts": [{"kind": "text", "text": text}], + "messageId": str(uuid.uuid4()), + "role": "ROLE_USER", + "parts": [{"text": text}], } } @@ -117,47 +123,64 @@ def test_agent_card_returns_card_data(self): 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"))) + client = TestClient(app, raise_server_exceptions=False, headers={"A2A-Version": "1.0"}) + resp = client.post("/", json=_jsonrpc_request("SendMessage", _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" + task = body["result"]["task"] + assert task["status"]["state"] == "TASK_STATE_COMPLETED" assert task["artifacts"][0]["parts"][0]["text"] == "echo: unit-test" + def test_v03_message_send_remains_compatible(self): + app = build_a2a_app(_EchoExecutor(), _make_agent_card()) + client = TestClient(app, raise_server_exceptions=False) + params = { + "message": { + "message_id": str(uuid.uuid4()), + "role": "user", + "parts": [{"kind": "text", "text": "compat"}], + } + } + + resp = client.post("/", json=_jsonrpc_request("message/send", params)) + + assert resp.status_code == 200 + assert resp.json()["result"]["status"]["state"] == "completed" + assert resp.json()["result"]["artifacts"][0]["parts"][0]["text"] == "echo: compat" + 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) + client = TestClient(app, raise_server_exceptions=False, headers={"A2A-Version": "1.0"}) # 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"] + resp = client.post("/", json=_jsonrpc_request("SendMessage", _send_message_params("store-test"))) + task_id = resp.json()["result"]["task"]["id"] # Retrieve from the same store via tasks/get - resp2 = client.post("/", json=_jsonrpc_request("tasks/get", {"id": task_id})) + resp2 = client.post("/", json=_jsonrpc_request("GetTask", {"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.""" + """When AGENTCORE_RUNTIME_URL is set, the JSON-RPC interface URL is overridden.""" card = _make_agent_card() - assert card.url == "http://localhost:9000" + assert card.supported_interfaces[0].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/" + assert card.supported_interfaces[0].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.""" + """When AGENTCORE_RUNTIME_URL is not set, the JSON-RPC interface URL stays as-is.""" card = _make_agent_card() - original_url = card.url + original_url = card.supported_interfaces[0].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 + assert card.supported_interfaces[0].url == original_url def test_auto_builds_card_when_none_provided(self): """When agent_card is omitted, a default card is built automatically.""" @@ -167,7 +190,7 @@ def test_auto_builds_card_when_none_provided(self): assert resp.status_code == 200 body = resp.json() assert body["name"] == "agent" - assert body["url"] == "http://localhost:9000/" + assert body["supportedInterfaces"][0]["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.""" @@ -192,7 +215,7 @@ def test_auto_card_uses_runtime_url_env(self): app = build_a2a_app(_EchoExecutor()) client = TestClient(app) resp = client.get("/.well-known/agent-card.json") - assert resp.json()["url"] == "https://prod.example.com/" + assert resp.json()["supportedInterfaces"][0]["url"] == "https://prod.example.com/" class TestBedrockCallContextBuilder: @@ -334,11 +357,11 @@ 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 = TestClient(app, raise_server_exceptions=False, headers={"A2A-Version": "1.0"}) client.post( "/", - json=_jsonrpc_request("message/send", _send_message_params("ctx-test")), + json=_jsonrpc_request("SendMessage", _send_message_params("ctx-test")), headers={ "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "unit-sess", "X-Amzn-Bedrock-AgentCore-Runtime-Request-Id": "unit-req", @@ -349,7 +372,7 @@ def _test(): 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["bedrock_request_id"] == "unit-req" assert ctx.state["workload_access_token"] == "unit-token" self._run_in_isolated_context(_test) diff --git a/tests/integration/runtime/test_a2a_integration.py b/tests/integration/runtime/test_a2a_integration.py index 69d6440e..cd171b9c 100644 --- a/tests/integration/runtime/test_a2a_integration.py +++ b/tests/integration/runtime/test_a2a_integration.py @@ -1,27 +1,25 @@ """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. +Uses real a2a-sdk v1 route factories + DefaultRequestHandler with a concrete +executor. Every test sends real HTTP requests through the full stack. """ import json import uuid import pytest +from a2a.helpers import new_task_from_user_message 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, + AgentInterface, AgentSkill, Part, - TextPart, - UnsupportedOperationError, ) -from a2a.utils import new_task -from a2a.utils.errors import ServerError +from a2a.utils.errors import UnsupportedOperationError from starlette.testclient import TestClient from bedrock_agentcore.runtime.a2a import BedrockCallContextBuilder, build_a2a_app @@ -36,7 +34,7 @@ def __init__(self): 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) + task = context.current_task or new_task_from_user_message(context.message) if not context.current_task: await event_queue.enqueue_event(task) updater = TaskUpdater(event_queue, task.id, task.context_id) @@ -44,23 +42,29 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non 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.add_artifact([Part(text=f"echo: {user_text}")]) await updater.complete() async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: - raise ServerError(error=UnsupportedOperationError()) + raise 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"], + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url="http://localhost:9000", + ) + ], ) @@ -74,9 +78,9 @@ def _jsonrpc_request(method: str, params: dict | None = None, req_id: int = 1) - def _send_message_params(text: str = "hello") -> dict: return { "message": { - "message_id": str(uuid.uuid4()), - "role": "user", - "parts": [{"kind": "text", "text": text}], + "messageId": str(uuid.uuid4()), + "role": "ROLE_USER", + "parts": [{"text": text}], } } @@ -90,7 +94,7 @@ def echo_executor(): 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) + return TestClient(app, raise_server_exceptions=False, headers={"A2A-Version": "1.0"}) @pytest.mark.integration @@ -98,13 +102,13 @@ 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")), + json=_jsonrpc_request("SendMessage", _send_message_params("hi")), ) assert resp.status_code == 200 body = resp.json() assert "result" in body - task = body["result"] - assert task["status"]["state"] == "completed" + task = body["result"]["task"] + assert task["status"]["state"] == "TASK_STATE_COMPLETED" artifacts = task["artifacts"] assert len(artifacts) == 1 assert artifacts[0]["parts"][0]["text"] == "echo: hi" @@ -112,12 +116,12 @@ def test_message_send_returns_completed_task_with_echo_artifact(self, a2a_client 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")), + json=_jsonrpc_request("SendStreamingMessage", _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 + # Parse SSE data lines. Each envelope contains one StreamResponse field. results = [] for line in resp.text.split("\n"): if line.startswith("data:"): @@ -129,45 +133,44 @@ def test_message_send_stream_produces_sse_with_artifact_and_status(self, a2a_cli 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}" + assert any("artifactUpdate" in result for result in results) + assert any("statusUpdate" in result for result in results) - # Verify the artifact content in the artifact-update event - artifact_event = next(r for r in results if r.get("kind") == "artifact-update") + # Verify the artifact content in the artifact update event + artifact_event = next(r["artifactUpdate"] for r in results if "artifactUpdate" in r) 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" + status_event = next(r["statusUpdate"] for r in results if "statusUpdate" in r) + assert status_event["status"]["state"] == "TASK_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")), + json=_jsonrpc_request("SendMessage", _send_message_params("for-get")), ) - task_id = send_resp.json()["result"]["id"] + task_id = send_resp.json()["result"]["task"]["id"] resp = a2a_client.post( "/", - json=_jsonrpc_request("tasks/get", {"id": task_id}), + json=_jsonrpc_request("GetTask", {"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"]["status"]["state"] == "TASK_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")), + json=_jsonrpc_request("SendMessage", _send_message_params("for-cancel")), ) - task_id = send_resp.json()["result"]["id"] + task_id = send_resp.json()["result"]["task"]["id"] resp = a2a_client.post( "/", - json=_jsonrpc_request("tasks/cancel", {"id": task_id}), + json=_jsonrpc_request("CancelTask", {"id": task_id}), ) assert resp.status_code == 200 body = resp.json() @@ -185,7 +188,7 @@ def test_unknown_method_returns_method_not_found(self, a2a_client): def test_invalid_params_returns_error(self, a2a_client): resp = a2a_client.post( "/", - json=_jsonrpc_request("message/send", {"bad_key": "bad_value"}), + json=_jsonrpc_request("SendMessage", {"bad_key": "bad_value"}), ) assert resp.status_code == 200 body = resp.json() @@ -215,8 +218,9 @@ def test_bedrock_headers_propagated_to_executor(self, echo_executor): resp = client.post( "/", - json=_jsonrpc_request("message/send", _send_message_params("headers-test")), + json=_jsonrpc_request("SendMessage", _send_message_params("headers-test")), headers={ + "A2A-Version": "1.0", "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "integ-sess-1", "X-Amzn-Bedrock-AgentCore-Runtime-Request-Id": "integ-req-1", "WorkloadAccessToken": "integ-token", @@ -225,13 +229,13 @@ def test_bedrock_headers_propagated_to_executor(self, echo_executor): ) assert resp.status_code == 200 # Verify the task completed (executor actually ran) - assert resp.json()["result"]["status"]["state"] == "completed" + assert resp.json()["result"]["task"]["status"]["state"] == "TASK_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["bedrock_request_id"] == "integ-req-1" assert ctx.state["workload_access_token"] == "integ-token" assert ctx.state["oauth2_callback_url"] == "https://callback.example.com" @@ -239,6 +243,6 @@ 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")), + json=_jsonrpc_request("SendMessage", _send_message_params("verify-input")), ) assert echo_executor.last_user_text == "verify-input" diff --git a/uv.lock b/uv.lock index 1bd84534..ed381a64 100644 --- a/uv.lock +++ b/uv.lock @@ -9,23 +9,25 @@ resolution-markers = [ [[package]] name = "a2a-sdk" -version = "0.3.25" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, { name = "google-api-core" }, + { name = "googleapis-common-protos" }, { name = "httpx" }, - { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/38/cc/59b35c518d8289bd59d20d9d216ca29ccb41c4697eb85971efe41d1adaf3/a2a_sdk-1.1.2.tar.gz", hash = "sha256:f928d8bf9a0dc0a473ee8258bd5226c1b8520a85c70e7f233c3b9b0e30a1bd10", size = 379627, upload-time = "2026-07-22T13:40:51.409Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/c1/33/d414a03d5ef5a7d6d09a20dc9f8094e7fb9edb488662ff99c535a2a62cf1/a2a_sdk-1.1.2-py3-none-any.whl", hash = "sha256:eb9a698f526dc45a9ea967d7804e7aa363ff273d2c44fbed699bc68c9399f9c9", size = 245981, upload-time = "2026-07-22T13:40:49.484Z" }, ] [package.optional-dependencies] http-server = [ - { name = "fastapi" }, { name = "sse-starlette" }, { name = "starlette" }, ] @@ -50,7 +52,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "caio" }, + { name = "caio", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } wheels = [ @@ -66,7 +68,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ - { name = "caio" }, + { name = "caio", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } wheels = [ @@ -203,25 +205,30 @@ wheels = [ ] [[package]] -name = "aiosignal" -version = "1.4.0" +name = "aiologic" +version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/5b/d3/2d310b1b839014034dba0cba685e492df8a5c7ad32c19cab7e979eed6554/aiologic-0.17.1-py3-none-any.whl", hash = "sha256:c66b319830fedb7ca3d2b2125fa6f5b653f89418c2a27ea76f259ec5f00943c0", size = 161331, upload-time = "2026-06-27T20:41:31.877Z" }, ] [[package]] -name = "annotated-doc" -version = "0.0.4" +name = "aiosignal" +version = "1.4.0" 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" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } 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" }, + { 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]] @@ -441,7 +448,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a'", specifier = ">=0.3,<1.0" }, + { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a'", specifier = ">=1.0.1,<2.0" }, { name = "ag-ui-protocol", marker = "extra == 'ag-ui'", specifier = ">=0.1.10" }, { name = "boto3", specifier = ">=1.43.31" }, { name = "botocore", specifier = ">=1.43.31" }, @@ -466,7 +473,7 @@ provides-extras = ["a2a", "ag-ui", "strands-agents", "langgraph", "strands-agent [package.metadata.requires-dev] dev = [ - { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3,<1.0" }, + { name = "a2a-sdk", extras = ["http-server"], specifier = ">=1.0.1,<2.0" }, { name = "ag-ui-protocol", specifier = ">=0.1.10" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "langchain", specifier = ">=1.0.0" }, @@ -870,6 +877,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "cyclopts" version = "4.16.1" @@ -957,22 +977,6 @@ 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 = "fastmcp" version = "3.3.1" @@ -1376,6 +1380,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/83/b6b62a66a06ce872d9429a5eb5ee20b2002fd9c331b953c94381c1f7c9f9/joserfc-1.7.0-py3-none-any.whl", hash = "sha256:17e5d7a5a35e65442b05efc435a3d5d46696ffa2c8a2ed0eea6f63fc268e3224", size = 70387, upload-time = "2026-06-02T09:59:33.264Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" From 84bc6ffc2d30eb4b09b6371527495aa496cdbfb1 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 23 Jul 2026 16:27:27 -0400 Subject: [PATCH 2/3] fix(a2a): honor PORT when serving locally --- docs/examples/a2a_protocol_examples.md | 4 ++-- src/bedrock_agentcore/runtime/a2a.py | 9 ++++++--- tests/bedrock_agentcore/runtime/test_a2a.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/examples/a2a_protocol_examples.md b/docs/examples/a2a_protocol_examples.md index 965d14e6..e8bbc9db 100644 --- a/docs/examples/a2a_protocol_examples.md +++ b/docs/examples/a2a_protocol_examples.md @@ -120,7 +120,7 @@ if __name__ == "__main__": ## API Reference -### `serve_a2a(executor, agent_card=None, *, port=9000, host=None, ...)` +### `serve_a2a(executor, agent_card=None, *, port=None, host=None, ...)` Starts a Bedrock-compatible A2A server with `uvicorn`. @@ -128,7 +128,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` | `9000` | Port to serve on | +| `port` | `int \| None` | `None` | Port to serve on. Uses `PORT`, then `9000`, when omitted | | `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` | diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index c4f50599..01b56ee7 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -287,7 +287,7 @@ def serve_a2a( executor: Any, agent_card: Any = None, *, - port: int = 9000, + port: Optional[int] = None, host: Optional[str] = None, task_store: Any = None, context_builder: Any = None, @@ -300,7 +300,8 @@ 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 (default 9000). + port: Port to serve on. Defaults to the ``PORT`` environment variable, + or 9000 when it is unset. host: Host to bind to; auto-detected if ``None``. task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``. context_builder: Optional ``ServerCallContextBuilder``; defaults to @@ -312,6 +313,8 @@ def serve_a2a( import uvicorn + resolved_port = port if port is not None else int(os.environ.get("PORT", "9000")) + app = build_a2a_app( executor, agent_card, @@ -328,7 +331,7 @@ def serve_a2a( uvicorn_params: dict[str, Any] = { "host": host, - "port": port, + "port": resolved_port, "log_level": "info", } uvicorn_params.update(kwargs) diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index 5cd6fcf5..ed942c3e 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -382,12 +382,27 @@ class TestServeA2A: @patch("uvicorn.run") def test_default_localhost(self, mock_uvicorn_run): with patch.dict("os.environ", {}, clear=False): + import os + + os.environ.pop("PORT", None) 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_port_from_environment(self, mock_uvicorn_run): + with patch.dict("os.environ", {"PORT": "9001"}): + serve_a2a(_EchoExecutor(), _make_agent_card()) + assert mock_uvicorn_run.call_args[1]["port"] == 9001 + + @patch("uvicorn.run") + def test_explicit_port_overrides_environment(self, mock_uvicorn_run): + with patch.dict("os.environ", {"PORT": "9001"}): + serve_a2a(_EchoExecutor(), _make_agent_card(), port=8888) + assert mock_uvicorn_run.call_args[1]["port"] == 8888 + @patch("uvicorn.run") def test_docker_detection_dockerenv(self, mock_uvicorn_run): with patch("os.path.exists", return_value=True): From 2b06b3b3255719b24a7b26042c86419b7b042837 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 24 Jul 2026 15:10:31 -0400 Subject: [PATCH 3/3] fix(a2a): advertise resolved local port --- src/bedrock_agentcore/runtime/a2a.py | 7 ++++++- tests/bedrock_agentcore/runtime/test_a2a.py | 10 +++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index 01b56ee7..5556f4e7 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -211,6 +211,7 @@ def build_a2a_app( executor: Any, agent_card: Any = None, *, + runtime_url: Optional[str] = None, task_store: Any = None, context_builder: Any = None, ping_handler: Optional[Callable[[], PingStatus]] = None, @@ -221,6 +222,9 @@ def build_a2a_app( 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. + runtime_url: URL advertised by an automatically generated agent card. + Defaults to ``http://localhost:9000/``. ``AGENTCORE_RUNTIME_URL`` + takes precedence when set. task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``. context_builder: Optional ``ServerCallContextBuilder``; defaults to ``BedrockCallContextBuilder``. @@ -240,7 +244,7 @@ def build_a2a_app( from starlette.responses import JSONResponse from starlette.routing import Route - runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, "http://localhost:9000/") + runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, runtime_url or "http://localhost:9000/") if agent_card is None: agent_card = _build_agent_card(executor, runtime_url) @@ -318,6 +322,7 @@ def serve_a2a( app = build_a2a_app( executor, agent_card, + runtime_url=f"http://localhost:{resolved_port}/", task_store=task_store, context_builder=context_builder, ping_handler=ping_handler, diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index ed942c3e..484e2111 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -393,9 +393,13 @@ 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"}): - serve_a2a(_EchoExecutor(), _make_agent_card()) - assert mock_uvicorn_run.call_args[1]["port"] == 9001 + with patch.dict("os.environ", {"PORT": "9001"}, clear=True): + serve_a2a(_EchoExecutor()) + + 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"] == 9001 + assert response.json()["supportedInterfaces"][0]["url"] == "http://localhost:9001/" @patch("uvicorn.run") def test_explicit_port_overrides_environment(self, mock_uvicorn_run):