Skip to content

Commit e6604e1

Browse files
google-genai-botcopybara-github
authored andcommitted
fix: serialize raw bytes as base64 in A2A converters
ADK's A2A converters used Pydantic model_dump() in default mode, leaving raw bytes (e.g. screenshots from computer_use) in the dict that is later placed into a google.protobuf.Struct, which rejects non-JSON types and crashes with ValueError: Unexpected type. Switch the converter model_dump calls to mode="json" so bytes are base64-encoded to JSON-safe strings. PiperOrigin-RevId: 951973549
1 parent df02689 commit e6604e1

4 files changed

Lines changed: 123 additions & 8 deletions

File tree

src/google/adk/a2a/converters/event_converter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def _serialize_metadata_value(value: Any) -> str:
9191
"""
9292
if hasattr(value, "model_dump"):
9393
try:
94-
return value.model_dump(exclude_none=True, by_alias=True)
94+
return value.model_dump(mode="json", exclude_none=True, by_alias=True)
9595
except Exception as e:
9696
logger.warning("Failed to serialize metadata value: %s", e)
9797
return str(value)

src/google/adk/a2a/converters/part_converter.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,9 @@ def apply_meta(p: a2a_types.Part, meta: dict[str, Any]) -> None:
239239
meta = {}
240240
if part.video_metadata:
241241
meta[_get_adk_metadata_key('video_metadata')] = (
242-
part.video_metadata.model_dump(by_alias=True, exclude_none=True)
242+
part.video_metadata.model_dump(
243+
mode='json', by_alias=True, exclude_none=True
244+
)
243245
)
244246
if part.part_metadata:
245247
meta.update(part.part_metadata)
@@ -274,7 +276,7 @@ def apply_meta(p: a2a_types.Part, meta: dict[str, Any]) -> None:
274276
).decode('utf-8')
275277
if part.part_metadata:
276278
meta.update(part.part_metadata)
277-
data_dict = val.model_dump(by_alias=True, exclude_none=True)
279+
data_dict = val.model_dump(mode='json', by_alias=True, exclude_none=True)
278280
return _compat.make_data_part(data=data_dict, metadata=meta)
279281

280282
logger.warning(

tests/unittests/a2a/converters/test_event_converter.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from google.adk.a2a.converters.utils import ADK_METADATA_KEY_PREFIX
3636
from google.adk.agents.invocation_context import InvocationContext
3737
from google.adk.events.event import Event
38+
from google.genai import types as genai_types
3839
import pytest
3940

4041

@@ -109,7 +110,7 @@ def test_serialize_metadata_value_with_model_dump(self):
109110

110111
assert result == {"key": "value"}
111112
mock_value.model_dump.assert_called_once_with(
112-
exclude_none=True, by_alias=True
113+
mode="json", exclude_none=True, by_alias=True
113114
)
114115

115116
def test_serialize_metadata_value_with_model_dump_exception(self):
@@ -131,6 +132,51 @@ def test_serialize_metadata_value_without_model_dump(self):
131132
result = _serialize_metadata_value(value)
132133
assert result == "simple_string"
133134

135+
def _serialized_metadata_with_bytes(self):
136+
value = genai_types.FunctionResponse(
137+
name="computer_use",
138+
response={"inline_data": {"data": b"\x89PNG_BYTES"}},
139+
)
140+
result = _serialize_metadata_value(value)
141+
142+
# No raw bytes anywhere in the serialized structure.
143+
def _assert_no_bytes(obj):
144+
if isinstance(obj, bytes):
145+
raise AssertionError("raw bytes leaked into serialized metadata")
146+
if isinstance(obj, dict):
147+
for v in obj.values():
148+
_assert_no_bytes(v)
149+
elif isinstance(obj, (list, tuple)):
150+
for v in obj:
151+
_assert_no_bytes(v)
152+
153+
_assert_no_bytes(result)
154+
return result
155+
156+
@pytest.mark.skipif(
157+
_compat.IS_A2A_V1, reason="0.3-only proto_utils.dict_to_struct"
158+
)
159+
def test_serialize_metadata_value_with_bytes_to_struct_v03(self):
160+
"""0.3: serialized metadata builds a proto Struct without raising."""
161+
from a2a.utils import proto_utils
162+
163+
result = self._serialized_metadata_with_bytes()
164+
struct = proto_utils.dict_to_struct({"meta": result})
165+
assert struct is not None
166+
167+
@pytest.mark.skipif(
168+
not _compat.IS_A2A_V1, reason="1.x-only ParseDict into proto Struct"
169+
)
170+
def test_serialize_metadata_value_with_bytes_to_struct_v1x(self):
171+
"""1.x: serialized metadata ParseDicts into a proto Struct."""
172+
from google.protobuf import struct_pb2
173+
from google.protobuf.json_format import ParseDict
174+
175+
result = self._serialized_metadata_with_bytes()
176+
struct = struct_pb2.Struct()
177+
ParseDict({"meta": result}, struct)
178+
assert struct is not None
179+
134180
def test_get_context_metadata_success(self):
135181
"""Test successful context metadata creation."""
136182
result = _get_context_metadata(
@@ -601,7 +647,6 @@ def test_create_status_update_event_with_input_required_state(self):
601647
def test_convert_event_to_a2a_message_with_multiple_parts_returned(self):
602648
"""Test event to message conversion when part_converter returns multiple parts."""
603649
from google.adk.a2a.converters.event_converter import convert_event_to_a2a_message
604-
from google.genai import types as genai_types
605650

606651
# Arrange
607652
mock_genai_part = genai_types.Part(text="source part")
@@ -815,7 +860,6 @@ def test_convert_a2a_task_to_event_message_conversion_error(self):
815860
def test_convert_a2a_message_to_event_success(self):
816861
"""Test successful conversion of A2A message to event."""
817862
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
818-
from google.genai import types as genai_types
819863

820864
# Use a real A2A part (production reads its metadata); the part_converter
821865
# callback is still mocked to return a canned genai Part.
@@ -844,7 +888,6 @@ def test_convert_a2a_message_to_event_success(self):
844888
def test_convert_a2a_message_to_event_with_multiple_parts_returned(self):
845889
"""Test message to event conversion when part_converter returns multiple parts."""
846890
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
847-
from google.genai import types as genai_types
848891

849892
# Arrange
850893
mock_a2a_part = _compat.make_text_part("part 1")
@@ -945,7 +988,6 @@ def test_convert_a2a_message_to_event_part_conversion_fails(self):
945988
def test_convert_a2a_message_to_event_part_conversion_exception(self):
946989
"""Test handling when part conversion raises exception."""
947990
from google.adk.a2a.converters.event_converter import convert_a2a_message_to_event
948-
from google.genai import types as genai_types
949991

950992
# Setup mock to raise exception. The A2A parts are real (production
951993
# reads their metadata); the converter callback drives the behavior.

tests/unittests/a2a/converters/test_part_converter.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,3 +1296,74 @@ def test_a2a_function_call_with_invalid_base64_thought_signature(self):
12961296
assert result.function_call.name == "invalid_sig_tool"
12971297
# thought_signature should be None due to decode failure
12981298
assert result.thought_signature is None
1299+
1300+
1301+
class TestBytesSerialization:
1302+
"""Tests that raw bytes serialize as base64 through the A2A converters."""
1303+
1304+
def _function_response_with_bytes(self) -> genai_types.Part:
1305+
screenshot = b"\x89PNG\r\n\x1a\n_FAKE_SCREENSHOT_" + bytes(range(16))
1306+
function_response = genai_types.FunctionResponse(
1307+
name="computer_use",
1308+
response={
1309+
"screenshot": {"inline_data": {"data": screenshot}},
1310+
"status": "ok",
1311+
},
1312+
)
1313+
return genai_types.Part(function_response=function_response)
1314+
1315+
def _assert_bytes_serialized_as_base64_str(self, a2a_part):
1316+
assert a2a_part is not None
1317+
assert _compat.is_data_part(a2a_part)
1318+
# Raw bytes must be serialized to a base64 str, not kept as bytes.
1319+
data_dict = _compat.data_part_dict(a2a_part)
1320+
serialized = data_dict["response"]["screenshot"]["inline_data"]["data"]
1321+
assert isinstance(serialized, str)
1322+
1323+
@pytest.mark.skipif(_compat.IS_A2A_V1, reason="0.3-only proto_utils.ToProto")
1324+
def test_function_response_with_bytes_serializes_to_proto_struct_v03(self):
1325+
"""0.3: the A2A DataPart serializes to a proto Struct without raising."""
1326+
from a2a.utils import proto_utils
1327+
1328+
a2a_part = convert_genai_part_to_a2a_part(
1329+
self._function_response_with_bytes()
1330+
)
1331+
self._assert_bytes_serialized_as_base64_str(a2a_part)
1332+
1333+
proto_part = proto_utils.ToProto.part(a2a_part)
1334+
assert proto_part is not None
1335+
1336+
@pytest.mark.skipif(
1337+
not _compat.IS_A2A_V1, reason="1.x-only proto Part / Struct"
1338+
)
1339+
def test_function_response_with_bytes_serializes_to_proto_struct_v1x(self):
1340+
"""1.x: conversion builds the proto Struct in place (via ParseDict)."""
1341+
from google.protobuf import struct_pb2
1342+
1343+
a2a_part = convert_genai_part_to_a2a_part(
1344+
self._function_response_with_bytes()
1345+
)
1346+
self._assert_bytes_serialized_as_base64_str(a2a_part)
1347+
1348+
# The proto Struct build already happened during conversion; assert it.
1349+
assert a2a_part.WhichOneof("content") == "data"
1350+
assert isinstance(a2a_part.data, struct_pb2.Value)
1351+
assert a2a_part.data.HasField("struct_value")
1352+
1353+
def test_function_response_with_bytes_round_trip(self):
1354+
"""genai -> a2a -> genai restores the original bytes losslessly."""
1355+
original = self._function_response_with_bytes()
1356+
a2a_part = convert_genai_part_to_a2a_part(original)
1357+
result = convert_a2a_part_to_genai_part(a2a_part)
1358+
1359+
assert result is not None
1360+
assert result.function_response is not None
1361+
restored = result.function_response.response["screenshot"]["inline_data"][
1362+
"data"
1363+
]
1364+
expected = original.function_response.response["screenshot"]["inline_data"][
1365+
"data"
1366+
]
1367+
if isinstance(restored, str):
1368+
restored = base64.b64decode(restored)
1369+
assert restored == expected

0 commit comments

Comments
 (0)