From 85cba30078b1b2dd152b6a934675dabc8c9ec750 Mon Sep 17 00:00:00 2001 From: A Vertex SDK engineer Date: Tue, 25 Aug 2026 01:36:30 -0700 Subject: [PATCH] fix: serve public A2A agent card at /a2a/card on Agent Runtime Deployed A2A agents on Agent Runtime returned 404 for GET .../a2a/card and .../a2a/v1/card because the A2aAgent template never wired the public agent card routes. This fix registers them in both vertex ai and agentplatform A2aAgent template copies so the public card is served at both paths. PiperOrigin-RevId: 970387377 --- agentplatform/agent_engines/templates/a2a.py | 11 +++ .../frameworks/test_frameworks_a2a.py | 67 +++++++++++++++++++ .../unit/vertex_a2a/test_agent_engines_a2a.py | 67 +++++++++++++++++++ vertexai/agent_engines/templates/a2a.py | 11 +++ 4 files changed, 156 insertions(+) diff --git a/agentplatform/agent_engines/templates/a2a.py b/agentplatform/agent_engines/templates/a2a.py index 705d57c428..638cba8645 100644 --- a/agentplatform/agent_engines/templates/a2a.py +++ b/agentplatform/agent_engines/templates/a2a.py @@ -314,6 +314,7 @@ def set_up(self): """Sets up the A2A application.""" # pylint: disable=g-import-not-at-top from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.routes.agent_card_routes import create_agent_card_routes from a2a.server.routes.rest_routes import create_rest_routes from a2a.server.tasks import InMemoryTaskStore @@ -388,6 +389,16 @@ def set_up(self): enable_v0_3_compat=enable_v0_3, path_prefix="/a2a", ) + # wire public agent card routes + card_routes = [] + for card_url in ("/a2a/card", "/a2a/v1/card"): + card_routes.extend( + create_agent_card_routes( + agent_card=self.agent_card, + card_url=card_url, + ) + ) + self.rest_routes = card_routes + self.rest_routes def __getattr__(self, name: str) -> Any: """Delegates all missing RequestHandler methods to the underlying request_handler.""" diff --git a/tests/unit/agentplatform/frameworks/test_frameworks_a2a.py b/tests/unit/agentplatform/frameworks/test_frameworks_a2a.py index 4fa0d87aff..dc1b5990ce 100644 --- a/tests/unit/agentplatform/frameworks/test_frameworks_a2a.py +++ b/tests/unit/agentplatform/frameworks/test_frameworks_a2a.py @@ -273,3 +273,70 @@ def test_create_agent_engine_with_invalid_agent_card( requirements=_TEST_AGENT_ENGINE_REQUIREMENTS, extra_packages=[_TEST_AGENT_ENGINE_EXTRA_PACKAGE_PATH], ) + + +class TestA2aPublicCardRoutes: + """Verifies the public agent card is served at the Agent Runtime card paths.""" + + def _build_agent(self): + """Builds an A2aAgent from the agentplatform template and runs set_up().""" + # pylint: disable=g-import-not-at-top + from a2a import types as a2a_types + from agentplatform.agent_engines.templates import ( + a2a as a2a_template, + ) + + # pylint: enable=g-import-not-at-top + card = a2a_types.AgentCard( + name="Test", + description="Test", + supported_interfaces=[ + a2a_types.AgentInterface( + url="http://example.com", + protocol_binding="HTTP+JSON", + protocol_version="1.0", + ) + ], + version="0.0.1", + capabilities=a2a_types.AgentCapabilities(), + skills=[ + a2a_types.AgentSkill( + id="hello_world", + name="Returns hello world", + description="just returns hello world", + tags=["hello world"], + examples=["hi"], + ) + ], + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + ) + agent = a2a_template.A2aAgent(agent_card=card) + with mock.patch.dict( + os.environ, + { + "GOOGLE_CLOUD_PROJECT": _TEST_PROJECT, + "GOOGLE_CLOUD_LOCATION": _TEST_LOCATION, + }, + ): + agent.set_up() + return agent + + def _client(self, agent): + """Builds a Starlette test client over the agent's registered routes.""" + # pylint: disable=g-import-not-at-top + from starlette.applications import Starlette + from starlette.testclient import TestClient + + # pylint: enable=g-import-not-at-top + return TestClient(Starlette(routes=agent.rest_routes)) + + def test_public_card_routes_not_shadowed_by_tenant_mount(self): + """The public card resolves and is not shadowed by the tenant Mount.""" + client = self._client(self._build_agent()) + for path in ("/a2a/card", "/a2a/v1/card"): + resp = client.get(path) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Test" + assert body["skills"][0]["id"] == "hello_world" diff --git a/tests/unit/vertex_a2a/test_agent_engines_a2a.py b/tests/unit/vertex_a2a/test_agent_engines_a2a.py index ace2e1b1ee..0b46aca96f 100644 --- a/tests/unit/vertex_a2a/test_agent_engines_a2a.py +++ b/tests/unit/vertex_a2a/test_agent_engines_a2a.py @@ -312,3 +312,70 @@ def test_create_agent_engine_with_invalid_agent_card( requirements=_TEST_AGENT_ENGINE_REQUIREMENTS, extra_packages=[_TEST_AGENT_ENGINE_EXTRA_PACKAGE_PATH], ) + + +class TestA2aPublicCardRoutes: + """Verifies the public agent card is served at the Agent Runtime card paths.""" + + def _build_agent(self): + """Builds an A2aAgent from the vertexai template and runs set_up().""" + # pylint: disable=g-import-not-at-top + from a2a import types as a2a_types + from vertexai.agent_engines.templates import ( + a2a as a2a_template, + ) + + # pylint: enable=g-import-not-at-top + card = a2a_types.AgentCard( + name="Test", + description="Test", + supported_interfaces=[ + a2a_types.AgentInterface( + url="http://example.com", + protocol_binding="HTTP+JSON", + protocol_version="1.0", + ) + ], + version="0.0.1", + capabilities=a2a_types.AgentCapabilities(), + skills=[ + a2a_types.AgentSkill( + id="hello_world", + name="Returns hello world", + description="just returns hello world", + tags=["hello world"], + examples=["hi"], + ) + ], + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + ) + agent = a2a_template.A2aAgent(agent_card=card) + with mock.patch.dict( + os.environ, + { + "GOOGLE_CLOUD_PROJECT": _TEST_PROJECT, + "GOOGLE_CLOUD_LOCATION": _TEST_LOCATION, + }, + ): + agent.set_up() + return agent + + def _client(self, agent): + """Builds a Starlette test client over the agent's registered routes.""" + # pylint: disable=g-import-not-at-top + from starlette.applications import Starlette + from starlette.testclient import TestClient + + # pylint: enable=g-import-not-at-top + return TestClient(Starlette(routes=agent.rest_routes)) + + def test_public_card_routes_not_shadowed_by_tenant_mount(self): + """The public card resolves and is not shadowed by the tenant Mount.""" + client = self._client(self._build_agent()) + for path in ("/a2a/card", "/a2a/v1/card"): + resp = client.get(path) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Test" + assert body["skills"][0]["id"] == "hello_world" diff --git a/vertexai/agent_engines/templates/a2a.py b/vertexai/agent_engines/templates/a2a.py index 705d57c428..638cba8645 100644 --- a/vertexai/agent_engines/templates/a2a.py +++ b/vertexai/agent_engines/templates/a2a.py @@ -314,6 +314,7 @@ def set_up(self): """Sets up the A2A application.""" # pylint: disable=g-import-not-at-top from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.routes.agent_card_routes import create_agent_card_routes from a2a.server.routes.rest_routes import create_rest_routes from a2a.server.tasks import InMemoryTaskStore @@ -388,6 +389,16 @@ def set_up(self): enable_v0_3_compat=enable_v0_3, path_prefix="/a2a", ) + # wire public agent card routes + card_routes = [] + for card_url in ("/a2a/card", "/a2a/v1/card"): + card_routes.extend( + create_agent_card_routes( + agent_card=self.agent_card, + card_url=card_url, + ) + ) + self.rest_routes = card_routes + self.rest_routes def __getattr__(self, name: str) -> Any: """Delegates all missing RequestHandler methods to the underlying request_handler."""