Skip to content

Commit 43de771

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: resolve AttributeError by supporting both Pydantic and Protobuf AgentCard serialization
PiperOrigin-RevId: 918921714
1 parent bf32f5e commit 43de771

4 files changed

Lines changed: 362 additions & 14 deletions

File tree

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

vertexai/_genai/_agent_engines_utils.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -652,10 +652,9 @@ def _generate_class_methods_spec_or_raise(
652652

653653
class_method = _to_proto(schema_dict)
654654
class_method[_MODE_KEY_IN_SCHEMA] = mode
655-
if hasattr(agent, "agent_card"):
656-
class_method[_A2A_AGENT_CARD] = json_format.MessageToJson(
657-
getattr(agent, "agent_card")
658-
)
655+
card = getattr(agent, "agent_card", None)
656+
if card is not None:
657+
class_method[_A2A_AGENT_CARD] = _serialize_agent_card_to_json(card)
659658
class_methods_spec.append(class_method)
660659

661660
return class_methods_spec
@@ -2148,3 +2147,59 @@ def _add_telemetry_enablement_env(
21482147
return env_vars
21492148

21502149
return env_vars | env_to_add
2150+
2151+
2152+
def _serialize_agent_card_to_dict(card: Any) -> Optional[Dict[str, Any]]:
2153+
"""Validates and serializes an AgentCard to a dictionary representation.
2154+
2155+
Args:
2156+
card: The AgentCard instance (Pydantic model or Protobuf Message).
2157+
2158+
Returns:
2159+
The serialized card as a dictionary.
2160+
2161+
Raises:
2162+
TypeError: If the card type is not supported.
2163+
"""
2164+
if card is None:
2165+
return None
2166+
2167+
if hasattr(card, "model_dump"):
2168+
return typing.cast(dict[str, Any], card.model_dump(exclude_none=True))
2169+
elif hasattr(card, "DESCRIPTOR"):
2170+
from google.protobuf import json_format
2171+
2172+
return typing.cast(dict[str, Any], json_format.MessageToDict(card))
2173+
else:
2174+
raise TypeError(
2175+
f"Unsupported AgentCard type: {type(card)}. "
2176+
"Only Pydantic models and Protobuf Messages are supported."
2177+
)
2178+
2179+
2180+
def _serialize_agent_card_to_json(card: Any) -> Optional[str]:
2181+
"""Validates and serializes an AgentCard to a JSON string representation.
2182+
2183+
Args:
2184+
card: The AgentCard instance (Pydantic model or Protobuf Message).
2185+
2186+
Returns:
2187+
The serialized card as a JSON string.
2188+
2189+
Raises:
2190+
TypeError: If the card type is not supported.
2191+
"""
2192+
if card is None:
2193+
return None
2194+
2195+
if hasattr(card, "model_dump_json"):
2196+
return typing.cast(str, card.model_dump_json())
2197+
elif hasattr(card, "DESCRIPTOR"):
2198+
from google.protobuf import json_format
2199+
2200+
return typing.cast(str, json_format.MessageToJson(card))
2201+
else:
2202+
raise TypeError(
2203+
f"Unsupported AgentCard type: {type(card)}. "
2204+
"Only Pydantic models and Protobuf Messages are supported."
2205+
)

vertexai/_genai/agent_engines.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2498,12 +2498,12 @@ def _create_config(
24982498

24992499
if hasattr(agent, "agent_card"):
25002500
agent_card = getattr(agent, "agent_card")
2501-
if agent_card:
2501+
if agent_card is not None:
25022502
try:
2503-
from google.protobuf import json_format
2504-
2505-
agent_engine_spec["agent_card"] = json_format.MessageToDict(
2506-
agent_card
2503+
agent_engine_spec["agent_card"] = (
2504+
_agent_engines_utils._serialize_agent_card_to_dict(
2505+
agent_card
2506+
)
25072507
)
25082508
except Exception as e:
25092509
raise ValueError(

0 commit comments

Comments
 (0)