Skip to content

Commit 00dbab7

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: resolve AttributeError by supporting both Pydantic and Protobuf AgentCard serialization
PiperOrigin-RevId: 930002178
1 parent 14a2265 commit 00dbab7

7 files changed

Lines changed: 407 additions & 14 deletions

File tree

noxfile.py

Lines changed: 28 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_agentplatform_a2a",
113114
"system",
114115
"cover",
115116
"lint",
@@ -222,6 +223,7 @@ def default(session):
222223
"--ignore=tests/unit/vertex_langchain",
223224
"--ignore=tests/unit/vertex_ag2",
224225
"--ignore=tests/unit/vertex_llama_index",
226+
"--ignore=tests/unit/vertex_a2a",
225227
"--ignore=tests/unit/architecture",
226228
"--ignore=tests/unit/vertexai/genai/replays",
227229
"--ignore=tests/unit/agentplatform/genai/replays",
@@ -429,6 +431,32 @@ def unit_agentplatform_llama_index(session):
429431
)
430432

431433

434+
@nox.session(python=UNIT_TEST_TEMPLATES_PYTHON_VERSIONS)
435+
def unit_agentplatform_a2a(session):
436+
# Install all test dependencies, then install this package in-place.
437+
438+
if session.python in ["3.10", "3.11", "3.12", "3.13"]:
439+
constraints_path = str(CURRENT_DIRECTORY / "testing" / "constraints-a2a-0.3.txt")
440+
else:
441+
constraints_path = str(CURRENT_DIRECTORY / "testing" / "constraints-a2a.txt")
442+
install_unittest_dependencies(session, "-c", constraints_path)
443+
session.install("a2a-sdk", "-c", constraints_path)
444+
445+
# Run py.test against the unit tests.
446+
session.run(
447+
"py.test",
448+
"--quiet",
449+
"--junitxml=unit_agentplatform_a2a_sponge_log.xml",
450+
"--cov=google",
451+
"--cov-append",
452+
"--cov-config=.coveragerc",
453+
"--cov-report=",
454+
"--cov-fail-under=0",
455+
os.path.join("tests", "unit", "vertex_a2a"),
456+
*session.posargs,
457+
)
458+
459+
432460
@nox.session(python=UNIT_TEST_TEMPLATES_PYTHON_VERSIONS)
433461
def unit_langchain(session):
434462
# Install all test dependencies, then install this package in-place.

testing/constraints-a2a-0.3.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
protobuf==3.20.3
2+
a2a-sdk==0.3.0

testing/constraints-a2a.txt

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

0 commit comments

Comments
 (0)