Skip to content

Commit 4c0c368

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: resolve AttributeError by supporting both Pydantic and Protobuf AgentCard serialization
PiperOrigin-RevId: 933648766
1 parent e083f13 commit 4c0c368

7 files changed

Lines changed: 423 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Format: //devtools/kokoro/config/proto/build.proto
2+
3+
# Run unit tests for A2A on Python 3.14
4+
env_vars: {
5+
key: "NOX_SESSION"
6+
value: "unit_a2a-3.14"
7+
}
8+
9+
# Run unit tests in parallel, splitting up by file
10+
env_vars: {
11+
key: "PYTEST_ADDOPTS"
12+
value: "-n=auto --dist=loadscope"
13+
}

noxfile.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"unit_agentplatform_langchain",
111111
"unit_agentplatform_ag2",
112112
"unit_agentplatform_llama_index",
113+
"unit_a2a",
113114
"system",
114115
"cover",
115116
"lint",
@@ -224,6 +225,7 @@ def default(session):
224225
"--ignore=tests/unit/vertex_langchain",
225226
"--ignore=tests/unit/vertex_ag2",
226227
"--ignore=tests/unit/vertex_llama_index",
228+
"--ignore=tests/unit/vertex_a2a",
227229
"--ignore=tests/unit/architecture",
228230
"--ignore=tests/unit/vertexai/genai/replays",
229231
"--ignore=tests/unit/agentplatform/genai/replays",
@@ -422,6 +424,29 @@ def unit_agentplatform_llama_index(session):
422424
)
423425

424426

427+
@nox.session(python=["3.14"])
428+
def unit_a2a(session):
429+
# Install all test dependencies, then install this package in-place.
430+
431+
constraints_path = str(CURRENT_DIRECTORY / "testing" / "constraints-a2a.txt")
432+
install_unittest_dependencies(session, "-c", constraints_path)
433+
session.install("a2a-sdk", "-c", constraints_path)
434+
435+
# Run py.test against the unit tests.
436+
session.run(
437+
"py.test",
438+
"--quiet",
439+
"--junitxml=unit_a2a_sponge_log.xml",
440+
"--cov=google",
441+
"--cov-append",
442+
"--cov-config=.coveragerc",
443+
"--cov-report=",
444+
"--cov-fail-under=0",
445+
os.path.join("tests", "unit", "vertex_a2a"),
446+
*session.posargs,
447+
)
448+
449+
425450
@nox.session(python=UNIT_TEST_TEMPLATES_PYTHON_VERSIONS)
426451
def unit_langchain(session):
427452
# Install all test dependencies, then install this package in-place.

testing/constraints-a2a.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
a2a-sdk>=1.0.0
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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+
16+
import os
17+
import sys
18+
import tempfile
19+
from unittest import mock
20+
import pytest
21+
import cloudpickle
22+
import pydantic
23+
24+
from google import auth
25+
from google.api_core import operation as ga_operation
26+
from google.auth import credentials as auth_credentials
27+
from google.cloud import storage
28+
from google.cloud import aiplatform
29+
from google.cloud.aiplatform import base
30+
31+
from google.cloud.aiplatform_v1 import types
32+
from google.cloud.aiplatform_v1.services import reasoning_engine_service
33+
from vertexai import agent_engines
34+
from vertexai.agent_engines import _agent_engines
35+
from vertexai.agent_engines import _utils
36+
from google.protobuf import struct_pb2
37+
38+
39+
class CapitalizeEngine:
40+
"""A sample Agent Engine."""
41+
42+
def query(self, unused_arbitrary_string_name: str) -> str:
43+
"""Runs the engine."""
44+
return unused_arbitrary_string_name.upper()
45+
46+
47+
class CapitalizeEngineWithCard(CapitalizeEngine):
48+
49+
def __init__(self, card):
50+
self.agent_card = card
51+
52+
def __getstate__(self):
53+
state = self.__dict__.copy()
54+
if hasattr(self.agent_card, "DESCRIPTOR"):
55+
state["agent_card"] = None
56+
return state
57+
58+
def __setstate__(self, state):
59+
self.__dict__.update(state)
60+
61+
62+
class DummyPydanticCard(pydantic.BaseModel):
63+
name: str = "test_pydantic_card"
64+
65+
66+
def _create_empty_fake_package(package_name: str) -> str:
67+
temp_dir = tempfile.mkdtemp()
68+
package_dir = os.path.join(temp_dir, package_name)
69+
os.makedirs(package_dir)
70+
init_path = os.path.join(package_dir, "__init__.py")
71+
open(init_path, "w").close()
72+
return temp_dir
73+
74+
75+
_TEST_CREDENTIALS = mock.Mock(spec=auth_credentials.AnonymousCredentials())
76+
_TEST_STAGING_BUCKET = "gs://test-bucket"
77+
_TEST_LOCATION = "us-central1"
78+
_TEST_PROJECT = "test-project"
79+
_TEST_RESOURCE_ID = "1028944691210842416"
80+
_TEST_PARENT = f"projects/{_TEST_PROJECT}/locations/{_TEST_LOCATION}"
81+
_TEST_AGENT_ENGINE_RESOURCE_NAME = (
82+
f"{_TEST_PARENT}/reasoningEngines/{_TEST_RESOURCE_ID}"
83+
)
84+
_TEST_AGENT_ENGINE_DISPLAY_NAME = "Agent Engine Display Name"
85+
_TEST_GCS_DIR_NAME = _agent_engines._DEFAULT_GCS_DIR_NAME
86+
_TEST_BLOB_FILENAME = _agent_engines._BLOB_FILENAME
87+
_TEST_REQUIREMENTS_FILE = _agent_engines._REQUIREMENTS_FILE
88+
_TEST_EXTRA_PACKAGES_FILE = _agent_engines._EXTRA_PACKAGES_FILE
89+
_TEST_STANDARD_API_MODE = _agent_engines._STANDARD_API_MODE
90+
_TEST_DEFAULT_METHOD_NAME = _agent_engines._DEFAULT_METHOD_NAME
91+
_TEST_MODE_KEY_IN_SCHEMA = _agent_engines._MODE_KEY_IN_SCHEMA
92+
93+
_TEST_AGENT_ENGINE_EXTRA_PACKAGE = "fake.py"
94+
95+
_TEST_AGENT_ENGINE_EXTRA_PACKAGE_PATH = _create_empty_fake_package(
96+
_TEST_AGENT_ENGINE_EXTRA_PACKAGE
97+
)
98+
99+
_TEST_AGENT_ENGINE_REQUIREMENTS = [
100+
"google-cloud-aiplatform==1.29.0",
101+
"langchain",
102+
]
103+
104+
_TEST_AGENT_ENGINE_GCS_URI = "{}/{}/{}".format(
105+
_TEST_STAGING_BUCKET,
106+
_TEST_GCS_DIR_NAME,
107+
_TEST_BLOB_FILENAME,
108+
)
109+
_TEST_AGENT_ENGINE_DEPENDENCY_FILES_GCS_URI = "{}/{}/{}".format(
110+
_TEST_STAGING_BUCKET,
111+
_TEST_GCS_DIR_NAME,
112+
_TEST_EXTRA_PACKAGES_FILE,
113+
)
114+
_TEST_AGENT_ENGINE_REQUIREMENTS_GCS_URI = "{}/{}/{}".format(
115+
_TEST_STAGING_BUCKET,
116+
_TEST_GCS_DIR_NAME,
117+
_TEST_REQUIREMENTS_FILE,
118+
)
119+
120+
_TEST_AGENT_ENGINE_QUERY_SCHEMA = _utils.to_proto(
121+
_utils.generate_schema(
122+
CapitalizeEngine().query,
123+
schema_name=_TEST_DEFAULT_METHOD_NAME,
124+
)
125+
)
126+
_TEST_AGENT_ENGINE_QUERY_SCHEMA[_TEST_MODE_KEY_IN_SCHEMA] = _TEST_STANDARD_API_MODE
127+
128+
_TEST_AGENT_ENGINE_PACKAGE_SPEC = types.ReasoningEngineSpec.PackageSpec(
129+
python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
130+
pickle_object_gcs_uri=_TEST_AGENT_ENGINE_GCS_URI,
131+
dependency_files_gcs_uri=_TEST_AGENT_ENGINE_DEPENDENCY_FILES_GCS_URI,
132+
requirements_gcs_uri=_TEST_AGENT_ENGINE_REQUIREMENTS_GCS_URI,
133+
)
134+
135+
_TEST_AGENT_ENGINE_OBJ = types.ReasoningEngine(
136+
name=_TEST_AGENT_ENGINE_RESOURCE_NAME,
137+
spec=types.ReasoningEngineSpec(
138+
package_spec=_TEST_AGENT_ENGINE_PACKAGE_SPEC,
139+
agent_framework=_agent_engines._DEFAULT_AGENT_FRAMEWORK,
140+
),
141+
)
142+
_TEST_AGENT_ENGINE_OBJ.spec.class_methods.append(_TEST_AGENT_ENGINE_QUERY_SCHEMA)
143+
144+
145+
@pytest.fixture(scope="module")
146+
def google_auth_mock():
147+
with mock.patch.object(auth, "default") as google_auth_mock:
148+
google_auth_mock.return_value = (
149+
auth_credentials.AnonymousCredentials(),
150+
_TEST_PROJECT,
151+
)
152+
yield google_auth_mock
153+
154+
155+
@pytest.fixture(scope="module")
156+
def cloud_storage_create_bucket_mock():
157+
with mock.patch.object(storage, "Client") as cloud_storage_mock:
158+
bucket_mock = mock.Mock(spec=storage.Bucket)
159+
bucket_mock.blob.return_value.open.return_value = "blob_file"
160+
bucket_mock.blob.return_value.upload_from_filename.return_value = None
161+
bucket_mock.blob.return_value.upload_from_string.return_value = None
162+
163+
cloud_storage_mock.get_bucket = mock.Mock(
164+
side_effect=ValueError("bucket not found")
165+
)
166+
cloud_storage_mock.bucket.return_value = bucket_mock
167+
cloud_storage_mock.create_bucket.return_value = bucket_mock
168+
169+
yield cloud_storage_mock
170+
171+
172+
@pytest.fixture(scope="module")
173+
def cloudpickle_load_mock():
174+
with mock.patch.object(cloudpickle, "load") as cloudpickle_load_mock:
175+
yield cloudpickle_load_mock
176+
177+
178+
@pytest.fixture(scope="module")
179+
def create_agent_engine_mock():
180+
with mock.patch.object(
181+
reasoning_engine_service.ReasoningEngineServiceClient,
182+
"create_reasoning_engine",
183+
) as create_agent_engine_mock:
184+
create_agent_engine_lro_mock = mock.Mock(spec=ga_operation.Operation)
185+
create_agent_engine_lro_mock.result.return_value = _TEST_AGENT_ENGINE_OBJ
186+
create_agent_engine_mock.return_value = create_agent_engine_lro_mock
187+
yield create_agent_engine_mock
188+
189+
190+
@pytest.fixture(scope="function")
191+
def get_gca_resource_mock():
192+
with mock.patch.object(
193+
base.VertexAiResourceNoun,
194+
"_get_gca_resource",
195+
) as get_gca_resource_mock:
196+
get_gca_resource_mock.return_value = _TEST_AGENT_ENGINE_OBJ
197+
yield get_gca_resource_mock
198+
199+
200+
@pytest.mark.usefixtures("google_auth_mock")
201+
class TestAgentEngineA2A:
202+
def setup_method(self):
203+
aiplatform.init(
204+
project=_TEST_PROJECT,
205+
location=_TEST_LOCATION,
206+
credentials=_TEST_CREDENTIALS,
207+
staging_bucket=_TEST_STAGING_BUCKET,
208+
)
209+
210+
def test_create_agent_engine_with_protobuf_agent_card(
211+
self,
212+
create_agent_engine_mock,
213+
cloud_storage_create_bucket_mock,
214+
cloudpickle_load_mock,
215+
get_gca_resource_mock,
216+
):
217+
a2a_pb2 = None
218+
# fmt: off
219+
try:
220+
try:
221+
from a2a.compat.v0_3 import a2a_v0_3_pb2 as a2a_pb2
222+
except ImportError:
223+
from a2a.grpc import a2a_pb2
224+
has_a2a_pb2 = True
225+
except (ImportError, TypeError):
226+
has_a2a_pb2 = False
227+
# fmt: on
228+
229+
if not has_a2a_pb2:
230+
pytest.skip("a2a_pb2 could not be imported.")
231+
232+
card = a2a_pb2.AgentCard(name="test_agent_card")
233+
agent = CapitalizeEngineWithCard(card)
234+
235+
agent_engines.create(
236+
agent,
237+
display_name=_TEST_AGENT_ENGINE_DISPLAY_NAME,
238+
requirements=_TEST_AGENT_ENGINE_REQUIREMENTS,
239+
extra_packages=[_TEST_AGENT_ENGINE_EXTRA_PACKAGE_PATH],
240+
)
241+
242+
expected_reasoning_engine = types.ReasoningEngine(
243+
display_name=_TEST_AGENT_ENGINE_DISPLAY_NAME,
244+
spec=types.ReasoningEngineSpec(
245+
package_spec=_TEST_AGENT_ENGINE_PACKAGE_SPEC,
246+
agent_framework=_agent_engines._DEFAULT_AGENT_FRAMEWORK,
247+
),
248+
)
249+
from google.protobuf import json_format
250+
251+
expected_class_method = struct_pb2.Struct()
252+
expected_class_method.CopyFrom(_TEST_AGENT_ENGINE_QUERY_SCHEMA)
253+
expected_class_method["a2a_agent_card"] = json_format.MessageToJson(card)
254+
expected_reasoning_engine.spec.class_methods.append(expected_class_method)
255+
256+
create_agent_engine_mock.assert_called_with(
257+
parent=_TEST_PARENT,
258+
reasoning_engine=expected_reasoning_engine,
259+
)
260+
261+
def test_create_agent_engine_with_pydantic_agent_card(
262+
self,
263+
create_agent_engine_mock,
264+
cloud_storage_create_bucket_mock,
265+
cloudpickle_load_mock,
266+
get_gca_resource_mock,
267+
):
268+
card = DummyPydanticCard()
269+
agent = CapitalizeEngineWithCard(card)
270+
271+
agent_engines.create(
272+
agent,
273+
display_name=_TEST_AGENT_ENGINE_DISPLAY_NAME,
274+
requirements=_TEST_AGENT_ENGINE_REQUIREMENTS,
275+
extra_packages=[_TEST_AGENT_ENGINE_EXTRA_PACKAGE_PATH],
276+
)
277+
278+
expected_reasoning_engine = types.ReasoningEngine(
279+
display_name=_TEST_AGENT_ENGINE_DISPLAY_NAME,
280+
spec=types.ReasoningEngineSpec(
281+
package_spec=_TEST_AGENT_ENGINE_PACKAGE_SPEC,
282+
agent_framework=_agent_engines._DEFAULT_AGENT_FRAMEWORK,
283+
),
284+
)
285+
286+
expected_class_method = struct_pb2.Struct()
287+
expected_class_method.CopyFrom(_TEST_AGENT_ENGINE_QUERY_SCHEMA)
288+
expected_class_method["a2a_agent_card"] = card.model_dump_json()
289+
expected_reasoning_engine.spec.class_methods.append(expected_class_method)
290+
291+
create_agent_engine_mock.assert_called_with(
292+
parent=_TEST_PARENT,
293+
reasoning_engine=expected_reasoning_engine,
294+
)
295+
296+
def test_create_agent_engine_with_invalid_agent_card(
297+
self,
298+
create_agent_engine_mock,
299+
cloud_storage_create_bucket_mock,
300+
cloudpickle_load_mock,
301+
get_gca_resource_mock,
302+
):
303+
agent = CapitalizeEngineWithCard(card="invalid_card_type_string")
304+
305+
with pytest.raises(
306+
TypeError,
307+
match="Unsupported AgentCard type",
308+
):
309+
agent_engines.create(
310+
agent,
311+
display_name=_TEST_AGENT_ENGINE_DISPLAY_NAME,
312+
requirements=_TEST_AGENT_ENGINE_REQUIREMENTS,
313+
extra_packages=[_TEST_AGENT_ENGINE_EXTRA_PACKAGE_PATH],
314+
)

0 commit comments

Comments
 (0)