Skip to content

Commit 1ae292f

Browse files
GWealecopybara-github
authored andcommitted
feat: support serving a to_a2a agent under a path prefix
to_a2a always mounted the JSON-RPC and agent-card routes at the root and advertised an RPC URL ending in "/", so several agents could not be hosted behind one gateway without colliding. Add an optional keyword-only rpc_path param that normalizes slashes, threads a prefix into the advertised RPC URL, and mounts both routes under that prefix. It defaults to "" so existing behavior (root mount) is unchanged. Close #4448 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 953444584
1 parent 07455ee commit 1ae292f

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

src/google/adk/a2a/utils/agent_to_a2a.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def to_a2a(
8181
host: str = "localhost",
8282
port: int = 8000,
8383
protocol: str = "http",
84+
rpc_path: str = "",
8485
agent_card: AgentCard | str | None = None,
8586
push_config_store: PushNotificationConfigStore | None = None,
8687
task_store: TaskStore | None = None,
@@ -95,6 +96,12 @@ def to_a2a(
9596
host: The host for the A2A RPC URL (default: "localhost")
9697
port: The port for the A2A RPC URL (default: 8000)
9798
protocol: The protocol for the A2A RPC URL (default: "http")
99+
rpc_path: Optional path prefix to serve the agent under, e.g.
100+
"analysis-agent". Leading/trailing slashes are ignored. When set, both
101+
the JSON-RPC route and the well-known agent-card route are mounted under
102+
this prefix instead of at the root. Defaults to "" (mount at root). A
103+
caller-provided agent_card's advertised url is not rewritten; a warning
104+
is logged if both agent_card and a non-empty rpc_path are supplied.
98105
agent_card: Optional pre-built AgentCard object or path to agent card
99106
JSON. If not provided, will be built automatically from the agent.
100107
push_config_store: Optional A2A push notification config store. If not
@@ -180,9 +187,19 @@ def create_runner() -> Runner:
180187
push_config_store = InMemoryPushNotificationConfigStore()
181188

182189
# Use provided agent card or build one from the agent
183-
rpc_url = f"{protocol}://{host}:{port}/"
190+
normalized_path = rpc_path.strip("/")
191+
prefix = f"/{normalized_path}" if normalized_path else ""
192+
rpc_url = f"{protocol}://{host}:{port}{prefix}/"
184193
provided_agent_card = _load_agent_card(agent_card)
185194

195+
if provided_agent_card is not None and normalized_path:
196+
adk_logger.warning(
197+
"Both agent_card and rpc_path were provided; routes are mounted under"
198+
" %r but the provided agent_card's advertised url is left unchanged, so"
199+
" clients reading the card may not reach the served location.",
200+
prefix,
201+
)
202+
186203
card_builder = AgentCardBuilder(
187204
agent=agent,
188205
rpc_url=rpc_url,
@@ -202,6 +219,7 @@ async def setup_a2a(app: Starlette):
202219
agent_executor=agent_executor,
203220
task_store=task_store,
204221
push_config_store=push_config_store,
222+
prefix=prefix,
205223
)
206224

207225
# Compose a lifespan that runs A2A setup and the user's lifespan

tests/unittests/a2a/utils/test_agent_to_a2a.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
(driving the app lifespan and asserting routes are attached).
2727
"""
2828

29+
import logging
2930
from unittest.mock import ANY
3031
from unittest.mock import AsyncMock
3132
from unittest.mock import Mock
@@ -225,6 +226,100 @@ def test_to_a2a_custom_host_port(
225226
agent=self.mock_agent, rpc_url="http://example.com:9000/"
226227
)
227228

229+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
230+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
231+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
232+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
233+
def test_to_a2a_with_rpc_path(
234+
self,
235+
mock_starlette_class,
236+
mock_card_builder_class,
237+
mock_task_store_class,
238+
mock_agent_executor_class,
239+
):
240+
"""rpc_path is threaded into the advertised RPC URL."""
241+
mock_starlette_class.return_value = Mock(spec=Starlette)
242+
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
243+
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
244+
245+
to_a2a(self.mock_agent, rpc_path="/analysis-agent")
246+
247+
mock_card_builder_class.assert_called_once_with(
248+
agent=self.mock_agent, rpc_url="http://localhost:8000/analysis-agent/"
249+
)
250+
251+
@pytest.mark.parametrize(
252+
"rpc_path", ["analysis-agent", "/analysis-agent", "/analysis-agent/"]
253+
)
254+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
255+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
256+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
257+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
258+
def test_to_a2a_rpc_path_normalizes_slashes(
259+
self,
260+
mock_starlette_class,
261+
mock_card_builder_class,
262+
mock_task_store_class,
263+
mock_agent_executor_class,
264+
rpc_path,
265+
):
266+
"""Leading/trailing slashes on rpc_path are normalized identically."""
267+
mock_starlette_class.return_value = Mock(spec=Starlette)
268+
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
269+
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
270+
271+
to_a2a(self.mock_agent, rpc_path=rpc_path)
272+
273+
mock_card_builder_class.assert_called_once_with(
274+
agent=self.mock_agent, rpc_url="http://localhost:8000/analysis-agent/"
275+
)
276+
277+
@pytest.mark.parametrize("rpc_path", ["/", "//", "///"])
278+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
279+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
280+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
281+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
282+
def test_to_a2a_all_slash_rpc_path_collapses_to_root(
283+
self,
284+
mock_starlette_class,
285+
mock_card_builder_class,
286+
mock_task_store_class,
287+
mock_agent_executor_class,
288+
rpc_path,
289+
):
290+
"""An all-slash rpc_path collapses to a root mount."""
291+
mock_starlette_class.return_value = Mock(spec=Starlette)
292+
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
293+
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
294+
295+
to_a2a(self.mock_agent, rpc_path=rpc_path)
296+
297+
mock_card_builder_class.assert_called_once_with(
298+
agent=self.mock_agent, rpc_url="http://localhost:8000/"
299+
)
300+
301+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
302+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
303+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
304+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
305+
def test_to_a2a_multi_segment_rpc_path(
306+
self,
307+
mock_starlette_class,
308+
mock_card_builder_class,
309+
mock_task_store_class,
310+
mock_agent_executor_class,
311+
):
312+
"""A multi-segment rpc_path is preserved in the RPC URL."""
313+
mock_starlette_class.return_value = Mock(spec=Starlette)
314+
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
315+
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
316+
317+
to_a2a(self.mock_agent, rpc_path="/team/agent")
318+
319+
mock_card_builder_class.assert_called_once_with(
320+
agent=self.mock_agent, rpc_url="http://localhost:8000/team/agent/"
321+
)
322+
228323
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
229324
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
230325
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@@ -518,6 +613,26 @@ async def test_setup_a2a_builds_card_and_attaches_routes(
518613
mock_card_builder.build.assert_called_once()
519614
_assert_a2a_routes_attached(app)
520615

616+
async def test_to_a2a_rpc_path_mounts_prefixed_routes(self):
617+
"""rpc_path mounts the JSON-RPC and agent-card routes under the prefix."""
618+
agent = LlmAgent(
619+
name="prefixed_agent", description="d", model="gemini-2.0-flash"
620+
)
621+
app = to_a2a(agent, port=8001, rpc_path="/analysis-agent")
622+
623+
async with app.router.lifespan_context(app):
624+
paths = _route_paths(app)
625+
626+
assert (
627+
"/analysis-agent" in paths
628+
), f"missing prefixed RPC route; got {paths}"
629+
assert any(
630+
p and p.startswith("/analysis-agent/.well-known") for p in paths
631+
), f"missing prefixed agent-card route; got {paths}"
632+
assert (
633+
"/" not in paths
634+
), f"root RPC route should not be mounted; got {paths}"
635+
521636
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
522637
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
523638
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
@@ -560,6 +675,34 @@ async def test_to_a2a_with_custom_agent_card_object(
560675
mock_card_builder.build.assert_not_called()
561676
_assert_a2a_routes_attached(app)
562677

678+
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
679+
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
680+
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")
681+
@patch("google.adk.a2a.utils.agent_to_a2a.Starlette")
682+
def test_to_a2a_warns_when_agent_card_and_rpc_path_both_set(
683+
self,
684+
mock_starlette_class,
685+
mock_card_builder_class,
686+
mock_task_store_class,
687+
mock_agent_executor_class,
688+
caplog,
689+
):
690+
"""A provided agent_card plus a non-empty rpc_path logs a mismatch warning."""
691+
mock_starlette_class.return_value = Mock(spec=Starlette)
692+
mock_agent_executor_class.return_value = Mock(spec=A2aAgentExecutor)
693+
mock_card_builder_class.return_value = Mock(spec=AgentCardBuilder)
694+
695+
with caplog.at_level(logging.WARNING, logger="google_adk"):
696+
to_a2a(
697+
self.mock_agent,
698+
rpc_path="/analysis-agent",
699+
agent_card=_make_minimal_agent_card(),
700+
)
701+
702+
assert any(
703+
"agent_card and rpc_path" in r.message for r in caplog.records
704+
), f"expected mismatch warning; got {[r.message for r in caplog.records]}"
705+
563706
@patch("google.adk.a2a.utils.agent_to_a2a.A2aAgentExecutor")
564707
@patch("google.adk.a2a.utils.agent_to_a2a.InMemoryTaskStore")
565708
@patch("google.adk.a2a.utils.agent_to_a2a.AgentCardBuilder")

0 commit comments

Comments
 (0)