Skip to content

Commit fa8de6e

Browse files
committed
feat: add A2A protocol support via serve_a2a and build_a2a_app
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)
1 parent cd2f2a0 commit fa8de6e

6 files changed

Lines changed: 881 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,11 @@ dev = [
150150
"wheel>=0.45.1",
151151
"strands-agents>=1.18.0",
152152
"strands-agents-evals>=0.1.0",
153+
"a2a-sdk[http-server]>=0.3",
153154
]
154155

155156
[project.optional-dependencies]
157+
a2a = ["a2a-sdk[http-server]>=0.3"]
156158
strands-agents = [
157159
"strands-agents>=1.1.0"
158160
]

src/bedrock_agentcore/runtime/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- BedrockAgentCoreContext: Agent identity context
77
"""
88

9+
from .a2a import BedrockCallContextBuilder, build_a2a_app, serve_a2a
910
from .agent_core_runtime_client import AgentCoreRuntimeClient
1011
from .app import BedrockAgentCoreApp
1112
from .context import BedrockAgentCoreContext, RequestContext
@@ -14,7 +15,10 @@
1415
__all__ = [
1516
"AgentCoreRuntimeClient",
1617
"BedrockAgentCoreApp",
18+
"BedrockCallContextBuilder",
1719
"RequestContext",
1820
"BedrockAgentCoreContext",
1921
"PingStatus",
22+
"build_a2a_app",
23+
"serve_a2a",
2024
]
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"""A2A protocol support for Bedrock AgentCore Runtime.
2+
3+
Provides Bedrock-specific glue around the official a2a-sdk, handling header
4+
extraction, health checks, and Docker host detection.
5+
"""
6+
7+
import logging
8+
import time
9+
import uuid
10+
from typing import Any, Callable, Optional
11+
12+
from .context import BedrockAgentCoreContext
13+
from .models import (
14+
ACCESS_TOKEN_HEADER,
15+
AGENTCORE_RUNTIME_URL_ENV,
16+
AUTHORIZATION_HEADER,
17+
CUSTOM_HEADER_PREFIX,
18+
OAUTH2_CALLBACK_URL_HEADER,
19+
REQUEST_ID_HEADER,
20+
SESSION_HEADER,
21+
PingStatus,
22+
)
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
def _check_a2a_sdk() -> None:
28+
"""Raise ImportError with install instructions if a2a-sdk is missing."""
29+
try:
30+
import a2a # noqa: F401
31+
except ImportError:
32+
raise ImportError(
33+
'a2a-sdk is required for A2A protocol support. Install it with: pip install "bedrock-agentcore[a2a]"'
34+
) from None
35+
36+
37+
def _build_agent_card(executor: Any, url: str) -> Any:
38+
"""Build an AgentCard by introspecting a StrandsA2AExecutor.
39+
40+
Extracts name/description from ``executor.agent``. Falls back to generic
41+
defaults for other executors.
42+
"""
43+
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
44+
45+
name = "agent"
46+
description = "A Bedrock AgentCore agent"
47+
48+
agent = getattr(executor, "agent", None)
49+
if agent is not None:
50+
name = getattr(agent, "name", None) or name
51+
description = getattr(agent, "description", None) or description
52+
53+
return AgentCard(
54+
name=name,
55+
description=description,
56+
url=url,
57+
version="0.1.0",
58+
capabilities=AgentCapabilities(streaming=True),
59+
skills=[AgentSkill(id="main", name=name, description=description, tags=["main"])],
60+
default_input_modes=["text"],
61+
default_output_modes=["text"],
62+
)
63+
64+
65+
class BedrockCallContextBuilder:
66+
"""Extracts Bedrock runtime headers and propagates them into BedrockAgentCoreContext.
67+
68+
Implements the a2a-sdk CallContextBuilder ABC so the A2A server
69+
automatically calls ``build()`` on every incoming request.
70+
"""
71+
72+
def build(self, request: Any) -> Any:
73+
"""Build a ServerCallContext from a Starlette Request.
74+
75+
Args:
76+
request: A Starlette Request object.
77+
78+
Returns:
79+
A ServerCallContext with Bedrock headers stored in ``state``.
80+
"""
81+
from a2a.server.context import ServerCallContext
82+
83+
headers = request.headers
84+
85+
request_id = headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4())
86+
session_id = headers.get(SESSION_HEADER)
87+
BedrockAgentCoreContext.set_request_context(request_id, session_id)
88+
89+
workload_access_token = headers.get(ACCESS_TOKEN_HEADER)
90+
if workload_access_token:
91+
BedrockAgentCoreContext.set_workload_access_token(workload_access_token)
92+
93+
oauth2_callback_url = headers.get(OAUTH2_CALLBACK_URL_HEADER)
94+
if oauth2_callback_url:
95+
BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url)
96+
97+
request_headers: dict[str, str] = {}
98+
authorization_header = headers.get(AUTHORIZATION_HEADER)
99+
if authorization_header is not None:
100+
request_headers[AUTHORIZATION_HEADER] = authorization_header
101+
for header_name, header_value in headers.items():
102+
if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()):
103+
request_headers[header_name] = header_value
104+
if request_headers:
105+
BedrockAgentCoreContext.set_request_headers(request_headers)
106+
107+
state = {
108+
"request_id": request_id,
109+
"session_id": session_id,
110+
}
111+
if workload_access_token:
112+
state["workload_access_token"] = workload_access_token
113+
if oauth2_callback_url:
114+
state["oauth2_callback_url"] = oauth2_callback_url
115+
116+
return ServerCallContext(state=state)
117+
118+
119+
# Register as a virtual subclass so isinstance checks pass without
120+
# requiring a2a-sdk to be importable at class-definition time.
121+
try:
122+
from a2a.server.apps import CallContextBuilder
123+
124+
CallContextBuilder.register(BedrockCallContextBuilder)
125+
except Exception: # pragma: no cover
126+
pass
127+
128+
129+
def build_a2a_app(
130+
executor: Any,
131+
agent_card: Any = None,
132+
*,
133+
task_store: Any = None,
134+
context_builder: Any = None,
135+
ping_handler: Optional[Callable[[], PingStatus]] = None,
136+
) -> Any:
137+
"""Build a Starlette app wired for A2A protocol with Bedrock extras.
138+
139+
Args:
140+
executor: An ``AgentExecutor`` that implements the agent logic.
141+
agent_card: Optional ``a2a.types.AgentCard`` describing the agent.
142+
If ``None``, one is built automatically by introspecting the executor.
143+
task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``.
144+
context_builder: Optional ``CallContextBuilder``; defaults to
145+
``BedrockCallContextBuilder``.
146+
ping_handler: Optional callback returning a ``PingStatus``.
147+
148+
Returns:
149+
A Starlette application.
150+
"""
151+
import os
152+
153+
_check_a2a_sdk()
154+
155+
from starlette.responses import JSONResponse
156+
from starlette.routing import Route
157+
158+
from a2a.server.apps import A2AStarletteApplication
159+
from a2a.server.request_handlers import DefaultRequestHandler
160+
from a2a.server.tasks import InMemoryTaskStore
161+
162+
runtime_url = os.environ.get(AGENTCORE_RUNTIME_URL_ENV, "http://localhost:9000/")
163+
164+
if agent_card is None:
165+
agent_card = _build_agent_card(executor, runtime_url)
166+
elif os.environ.get(AGENTCORE_RUNTIME_URL_ENV):
167+
agent_card.url = runtime_url
168+
169+
if task_store is None:
170+
task_store = InMemoryTaskStore()
171+
if context_builder is None:
172+
context_builder = BedrockCallContextBuilder()
173+
174+
http_handler = DefaultRequestHandler(
175+
agent_executor=executor,
176+
task_store=task_store,
177+
)
178+
179+
a2a_app = A2AStarletteApplication(
180+
agent_card=agent_card,
181+
http_handler=http_handler,
182+
context_builder=context_builder,
183+
)
184+
185+
app = a2a_app.build()
186+
187+
last_status_update_time = time.time()
188+
189+
def _handle_ping(request: Any) -> JSONResponse:
190+
nonlocal last_status_update_time
191+
try:
192+
if ping_handler is not None:
193+
status = ping_handler()
194+
else:
195+
status = PingStatus.HEALTHY
196+
last_status_update_time = time.time()
197+
except Exception:
198+
logger.exception("Custom ping handler failed, falling back to Healthy")
199+
status = PingStatus.HEALTHY
200+
return JSONResponse({"status": status.value, "time_of_last_update": int(last_status_update_time)})
201+
202+
app.routes.append(Route("/ping", _handle_ping, methods=["GET"]))
203+
204+
return app
205+
206+
207+
def serve_a2a(
208+
executor: Any,
209+
agent_card: Any = None,
210+
*,
211+
port: int = 9000,
212+
host: Optional[str] = None,
213+
task_store: Any = None,
214+
context_builder: Any = None,
215+
ping_handler: Optional[Callable[[], PingStatus]] = None,
216+
**kwargs: Any,
217+
) -> None:
218+
"""Start a Bedrock-compatible A2A server.
219+
220+
Args:
221+
executor: An ``AgentExecutor`` that implements the agent logic.
222+
agent_card: Optional ``a2a.types.AgentCard`` describing the agent.
223+
If ``None``, one is built automatically by introspecting the executor.
224+
port: Port to serve on (default 9000).
225+
host: Host to bind to; auto-detected if ``None``.
226+
task_store: Optional ``TaskStore``; defaults to ``InMemoryTaskStore``.
227+
context_builder: Optional ``CallContextBuilder``; defaults to
228+
``BedrockCallContextBuilder``.
229+
ping_handler: Optional callback returning a ``PingStatus``.
230+
**kwargs: Additional arguments forwarded to ``uvicorn.run()``.
231+
"""
232+
import os
233+
234+
import uvicorn
235+
236+
app = build_a2a_app(
237+
executor,
238+
agent_card,
239+
task_store=task_store,
240+
context_builder=context_builder,
241+
ping_handler=ping_handler,
242+
)
243+
244+
if host is None:
245+
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_CONTAINER"):
246+
host = "0.0.0.0" # nosec B104 - Container needs this to expose the port
247+
else:
248+
host = "127.0.0.1"
249+
250+
uvicorn_params: dict[str, Any] = {
251+
"host": host,
252+
"port": port,
253+
"log_level": "info",
254+
}
255+
uvicorn_params.update(kwargs)
256+
257+
uvicorn.run(app, **uvicorn_params)

src/bedrock_agentcore/runtime/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class PingStatus(str, Enum):
2020
OAUTH2_CALLBACK_URL_HEADER = "OAuth2CallbackUrl"
2121
AUTHORIZATION_HEADER = "Authorization"
2222
CUSTOM_HEADER_PREFIX = "X-Amzn-Bedrock-AgentCore-Runtime-Custom-"
23+
AGENTCORE_RUNTIME_URL_ENV = "AGENTCORE_RUNTIME_URL"
2324

2425
# Task action constants
2526
TASK_ACTION_PING_STATUS = "ping_status"

0 commit comments

Comments
 (0)