Skip to content

Commit 0219a31

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: update agent engine utils in vertex_ai and agentplatform to support python-a2a sdk 1.0
PiperOrigin-RevId: 913139425
1 parent a76a4b6 commit 0219a31

3 files changed

Lines changed: 327 additions & 98 deletions

File tree

agentplatform/_genai/_agent_engines_utils.py

Lines changed: 54 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -111,30 +111,18 @@
111111

112112

113113
try:
114-
from a2a.types import (
115-
AgentCard,
116-
TransportProtocol,
117-
Message,
118-
TaskIdParams,
119-
TaskQueryParams,
120-
)
114+
from a2a.types import AgentCard
121115
from a2a.client import ClientConfig, ClientFactory
122-
123-
AgentCard = AgentCard
124-
TransportProtocol = TransportProtocol
125-
Message = Message
126-
ClientConfig = ClientConfig
127-
ClientFactory = ClientFactory
128-
TaskIdParams = TaskIdParams
129-
TaskQueryParams = TaskQueryParams
116+
from a2a.utils.constants import TransportProtocol
130117
except (ImportError, AttributeError):
131118
AgentCard = None
132119
TransportProtocol = None
133-
Message = None
134120
ClientConfig = None
135121
ClientFactory = None
136-
TaskIdParams = None
137-
TaskQueryParams = None
122+
SendMessageRequest = None
123+
GetTaskRequest = None
124+
CancelTaskRequest = None
125+
GetExtendedAgentCardRequest = None
138126
try:
139127
from autogen.agentchat import chat
140128

@@ -1807,79 +1795,53 @@ def _wrap_a2a_operation(method_name: str, agent_card: str) -> Callable[..., list
18071795
Args:
18081796
method_name: The name of the Agent Engine method to call.
18091797
agent_card: The agent card to use for the A2A API call.
1810-
Example:
1811-
{'additionalInterfaces': None,
1812-
'capabilities': {'extensions': None,
1813-
'pushNotifications': None,
1814-
'stateTransitionHistory': None,
1815-
'streaming': False},
1816-
'defaultInputModes': ['text'],
1817-
'defaultOutputModes': ['text'],
1818-
'description': (
1819-
'A helpful assistant agent that can answer questions.'
1820-
),
1821-
'documentationUrl': None,
1822-
'iconUrl': None,
1823-
'name': 'Q&A Agent',
1824-
'preferredTransport': 'JSONRPC',
1825-
'protocolVersion': '0.3.0',
1826-
'provider': None,
1827-
'security': None,
1828-
'securitySchemes': None,
1829-
'signatures': None,
1830-
'skills': [{
1831-
'description': (
1832-
'A helpful assistant agent that can answer questions.'
1833-
),
1834-
'examples': ['Who is leading 2025 F1 Standings?',
1835-
'Where can i find an active volcano?'],
1836-
'id': 'question_answer',
1837-
'inputModes': None,
1838-
'name': 'Q&A Agent',
1839-
'outputModes': None,
1840-
'security': None,
1841-
'tags': ['Question-Answer']}],
1842-
'supportsAuthenticatedExtendedCard': True,
1843-
'url': 'http://localhost:8080/',
1844-
'version': '1.0.0'}
1798+
Example: { 'name': 'Sample Agent', 'description': ( 'A helpful
1799+
assistant agent that can answer questions.' ),
1800+
'supportedInterfaces': [{ 'url': 'http://localhost:8080/a2a/rest/',
1801+
'protocolBinding': 'HTTP+JSON', 'protocolVersion': '1.0', }],
1802+
'version': '1.0.0', 'capabilities': { 'streaming': True,
1803+
'pushNotifications': False, 'extendedAgentCard': True, },
1804+
'defaultInputModes': ['text'], 'defaultOutputModes': ['text'],
1805+
'skills': [{ 'id': 'question_answer', 'name': 'Q&A Agent',
1806+
'description': ( 'A helpful assistant agent that can answer
1807+
questions.' ), 'tags': ['Question-Answer'], 'examples': [ 'Who is
1808+
leading 2025 F1 Standings?', 'Where can i find an active volcano?',
1809+
], 'inputModes': ['text'], 'outputModes': ['text'], }], }
1810+
18451811
Returns:
18461812
A callable object that executes the method on the Agent Engine via
18471813
the A2A API.
18481814
"""
18491815

18501816
async def _method(self, **kwargs) -> Any: # type: ignore[no-untyped-def]
1851-
"""Wraps an Agent Engine method, creating a callable for A2A API."""
18521817
if not self.api_client:
18531818
raise ValueError("api_client is not initialized.")
18541819
if not self.api_resource:
18551820
raise ValueError("api_resource is not initialized.")
1856-
a2a_agent_card = AgentCard(**json.loads(agent_card))
1857-
# A2A + AE integration currently only supports Rest API.
1858-
if (
1859-
a2a_agent_card.preferred_transport
1860-
and a2a_agent_card.preferred_transport != TransportProtocol.http_json
1861-
):
1862-
raise ValueError(
1863-
"Only HTTP+JSON is supported for preferred transport on agent card "
1864-
)
18651821

1866-
# Set preferred transport to HTTP+JSON if not set.
1867-
if not hasattr(a2a_agent_card, "preferred_transport"):
1868-
a2a_agent_card.preferred_transport = TransportProtocol.http_json
1822+
a2a_agent_card = AgentCard()
1823+
json_format.ParseDict(
1824+
json.loads(agent_card), a2a_agent_card, ignore_unknown_fields=True
1825+
)
18691826

1870-
if not hasattr(a2a_agent_card.capabilities, "streaming"):
1871-
a2a_agent_card.capabilities.streaming = False
1827+
if a2a_agent_card.supported_interfaces:
1828+
interface = a2a_agent_card.supported_interfaces[0]
1829+
if interface.protocol_binding != TransportProtocol.HTTP_JSON:
1830+
raise ValueError(
1831+
"Only HTTP+JSON is supported for preferred transport on agent card"
1832+
)
1833+
else:
1834+
raise ValueError("Agent card does not define any supported interfaces.")
18721835

1873-
# agent_card is set on the class_methods before set_up is invoked.
1874-
# Ensure that the agent_card url is set correctly before the client is created.
18751836
base_url = self.api_client._api_client._http_options.base_url.rstrip("/")
18761837
api_version = self.api_client._api_client._http_options.api_version
1877-
a2a_agent_card.url = f"{base_url}/{api_version}/{self.api_resource.name}/a2a"
1838+
a2a_agent_card.supported_interfaces[0].url = (
1839+
f"{base_url}/{api_version}/{self.api_resource.name}/a2a"
1840+
)
18781841

1879-
# Using a2a client, inject the auth token from the global config.
18801842
config = ClientConfig(
1881-
supported_transports=[
1882-
TransportProtocol.http_json,
1843+
supported_protocol_bindings=[
1844+
TransportProtocol.HTTP_JSON,
18831845
],
18841846
use_client_preference=True,
18851847
httpx_client=httpx.AsyncClient(
@@ -1898,23 +1860,34 @@ async def _method(self, **kwargs) -> Any: # type: ignore[no-untyped-def]
18981860
factory = ClientFactory(config)
18991861
client = factory.create(a2a_agent_card)
19001862

1863+
context = kwargs.pop("context", None)
1864+
if context is not None:
1865+
from a2a.client.client import ClientCallContext
1866+
1867+
if not isinstance(context, ClientCallContext):
1868+
actual_context = ClientCallContext()
1869+
if hasattr(context, "state"):
1870+
actual_context.state = context.state
1871+
elif isinstance(context, dict):
1872+
actual_context.state = context
1873+
context = actual_context
1874+
1875+
req = kwargs["request"]
19011876
if method_name == "on_message_send":
1902-
response = client.send_message(Message(**kwargs))
1877+
response = client.send_message(req, context=context)
19031878
chunks = []
19041879
async for chunk in response:
19051880
chunks.append(chunk)
19061881
return chunks
19071882
elif method_name == "on_get_task":
1908-
response = await client.get_task(TaskQueryParams(**kwargs))
1883+
return await client.get_task(req, context=context)
19091884
elif method_name == "on_cancel_task":
1910-
response = await client.cancel_task(TaskIdParams(**kwargs))
1911-
elif method_name == "handle_authenticated_agent_card":
1912-
response = await client.get_card()
1885+
return await client.cancel_task(req, context=context)
1886+
elif method_name == "on_get_extended_agent_card":
1887+
return await client.get_extended_agent_card(req, context=context)
19131888
else:
19141889
raise ValueError(f"Unknown method name: {method_name}")
19151890

1916-
return response
1917-
19181891
return _method # type: ignore[return-value]
19191892

19201893

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# pylint: disable=protected-access,bad-continuation,missing-function-docstring
16+
17+
from unittest import mock
18+
19+
from tests.unit.agentplatform.genai.replays import pytest_helper
20+
from agentplatform._genai import types
21+
from google.genai import _api_client
22+
import httpx
23+
import pytest
24+
25+
26+
# These tests target a2a-sdk 1.0, where the request/response types are protobuf
27+
# messages and client errors are surfaced as `A2AClientError`.
28+
pytest.importorskip(
29+
"a2a.client.errors", reason="a2a-sdk not installed, skipping Agent Engine A2A tests"
30+
)
31+
from a2a.client import ( # noqa: E402 # pylint: disable=g-import-not-at-top,g-bad-import-order
32+
errors as a2a_errors,
33+
)
34+
from a2a import types as a2a_types # noqa: E402
35+
36+
pytest_plugins = ("pytest_asyncio",)
37+
38+
39+
def _build_send_message_request() -> "a2a_types.SendMessageRequest":
40+
"""Builds an a2a 1.0 SendMessageRequest proto for on_message_send."""
41+
return a2a_types.SendMessageRequest(
42+
message=a2a_types.Message(
43+
message_id="msg-123",
44+
role=a2a_types.Role.ROLE_USER,
45+
parts=[a2a_types.Part(text="Where will be the Super Bowl held in 2026?")],
46+
)
47+
)
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_timeout_is_set(client):
52+
agent_engine = client.agent_engines.get(
53+
name="projects/964831358985/locations/us-central1/reasoningEngines/6859679872613089280",
54+
)
55+
assert isinstance(agent_engine, types.AgentEngine)
56+
57+
with mock.patch(
58+
"httpx.AsyncClient", spec=httpx.AsyncClient
59+
) as mock_async_client_factory:
60+
# Replay mode does not capture A2A calls so instead of relying on the
61+
# real service, we simulate a failed call.
62+
mock_response = httpx.Response(
63+
401,
64+
request=httpx.Request("POST", "url"),
65+
json={
66+
"error": {
67+
"code": "UNAUTHENTICATED",
68+
"message": "Authentication failed: Missing or invalid API key.",
69+
}
70+
},
71+
)
72+
mock_async_client_instance = mock_async_client_factory.return_value
73+
mock_async_client_instance.post.return_value = mock_response
74+
mock_async_client_instance.send.return_value = mock_response
75+
76+
# These credentials are missing in replay mode, so we need to set a fake
77+
# value. (This is not necessary in record mode.)
78+
class FakeCredentials:
79+
token = "fake-token"
80+
81+
agent_engine.api_client._api_client._credentials = FakeCredentials()
82+
83+
# In a2a 1.0 the wrapped operation forwards the `request` kwarg directly
84+
# to `client.send_message(request)`, and HTTP failures surface as an
85+
# `A2AClientError` (the legacy `A2AClientHTTPError` no longer exists).
86+
with pytest.raises(a2a_errors.A2AClientError) as exc_info:
87+
await agent_engine.on_message_send(request=_build_send_message_request())
88+
89+
# Make sure the authentication failure was propagated, otherwise the
90+
# test is not validating the request path.
91+
assert "401" in str(exc_info.value)
92+
93+
mock_async_client_factory.assert_called_once()
94+
assert mock_async_client_factory.call_args.kwargs["timeout"] == 99.0
95+
96+
97+
pytestmark = pytest_helper.setup(
98+
file=__file__,
99+
globals_for_file=globals(),
100+
test_method="agent_engines.get",
101+
http_options=_api_client.HttpOptions(timeout=99000),
102+
)

0 commit comments

Comments
 (0)