Skip to content

Commit 1133ce2

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: convert A2UI messages between A2A DataPart metadata and ADK events
1. Convert A2A responses containing a DataPart to ADK events. By default, this is done by serializing the DataPart to JSON and embedding it within the inline_data field of a GenAI Part, wrapped with custom tags (<a2a_datapart_json> and </a2a_datapart_json>). 2. Convert ADK events back to A2A requests. Specifically, messages stored in inline_data with the text/plain mime type and content wrapped within the custom tags (<a2a_datapart_json> and </a2a_datapart_json>) are deserialized from JSON back into an A2A DataPart PiperOrigin-RevId: 856426615
1 parent 712b5a3 commit 1133ce2

File tree

2 files changed

+174
-53
lines changed

2 files changed

+174
-53
lines changed

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE = 'function_response'
4141
A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT = 'code_execution_result'
4242
A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE = 'executable_code'
43+
A2A_DATA_PART_TEXT_MIME_TYPE = 'text/plain'
44+
A2A_DATA_PART_START_TAG = b'<a2a_datapart_json>'
45+
A2A_DATA_PART_END_TAG = b'</a2a_datapart_json>'
4346

4447

4548
A2APartToGenAIPartConverter = Callable[
@@ -130,7 +133,16 @@ def convert_a2a_part_to_genai_part(
130133
part.data, by_alias=True
131134
)
132135
)
133-
return genai_types.Part(text=json.dumps(part.data))
136+
return genai_types.Part(
137+
inline_data=genai_types.Blob(
138+
data=A2A_DATA_PART_START_TAG
139+
+ part.model_dump_json(by_alias=True, exclude_none=True).encode(
140+
'utf-8'
141+
)
142+
+ A2A_DATA_PART_END_TAG,
143+
mime_type=A2A_DATA_PART_TEXT_MIME_TYPE,
144+
)
145+
)
134146

135147
logger.warning(
136148
'Cannot convert unsupported part type: %s for A2A part: %s',
@@ -163,6 +175,20 @@ def convert_genai_part_to_a2a_part(
163175
)
164176

165177
if part.inline_data:
178+
if (
179+
part.inline_data.mime_type == A2A_DATA_PART_TEXT_MIME_TYPE
180+
and part.inline_data.data is not None
181+
and part.inline_data.data.startswith(A2A_DATA_PART_START_TAG)
182+
and part.inline_data.data.endswith(A2A_DATA_PART_END_TAG)
183+
):
184+
return a2a_types.Part(
185+
root=a2a_types.DataPart.model_validate_json(
186+
part.inline_data.data[
187+
len(A2A_DATA_PART_START_TAG) : -len(A2A_DATA_PART_END_TAG)
188+
]
189+
)
190+
)
191+
# The default case for inline_data is to convert it to FileWithBytes.
166192
a2a_part = a2a_types.FilePart(
167193
file=a2a_types.FileWithBytes(
168194
bytes=base64.b64encode(part.inline_data.data).decode('utf-8'),

tests/unittests/a2a/converters/test_part_converter.py

Lines changed: 147 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@
1717
from unittest.mock import patch
1818

1919
from a2a import types as a2a_types
20+
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_END_TAG
2021
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT
2122
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE
2223
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL
2324
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE
2425
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_METADATA_TYPE_KEY
26+
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_START_TAG
27+
from google.adk.a2a.converters.part_converter import A2A_DATA_PART_TEXT_MIME_TYPE
2528
from google.adk.a2a.converters.part_converter import convert_a2a_part_to_genai_part
2629
from google.adk.a2a.converters.part_converter import convert_genai_part_to_a2a_part
2730
from google.adk.a2a.converters.utils import _get_adk_metadata_key
@@ -154,12 +157,43 @@ def test_convert_data_part_function_response(self):
154157
"data": [1, 2, 3],
155158
}
156159

157-
def test_convert_data_part_without_special_metadata(self):
158-
"""Test conversion of A2A DataPart without special metadata to text."""
160+
@pytest.mark.parametrize(
161+
"test_name, data, metadata",
162+
[
163+
(
164+
"without_special_metadata",
165+
{"key": "value", "number": 123},
166+
{"other": "metadata"},
167+
),
168+
(
169+
"no_metadata",
170+
{"key": "value", "array": [1, 2, 3]},
171+
None,
172+
),
173+
(
174+
"complex_data",
175+
{
176+
"nested": {
177+
"array": [1, 2, {"inner": "value"}],
178+
"boolean": True,
179+
"null_value": None,
180+
},
181+
"unicode": "Hello 世界 🌍",
182+
},
183+
None,
184+
),
185+
(
186+
"empty_metadata",
187+
{"key": "value"},
188+
{},
189+
),
190+
],
191+
)
192+
def test_convert_data_part_to_inline_data(self, test_name, data, metadata):
193+
"""Test conversion of A2A DataPart to GenAI inline_data Part."""
159194
# Arrange
160-
data = {"key": "value", "number": 123}
161195
a2a_part = a2a_types.Part(
162-
root=a2a_types.DataPart(data=data, metadata={"other": "metadata"})
196+
root=a2a_types.DataPart(data=data, metadata=metadata)
163197
)
164198

165199
# Act
@@ -168,21 +202,17 @@ def test_convert_data_part_without_special_metadata(self):
168202
# Assert
169203
assert result is not None
170204
assert isinstance(result, genai_types.Part)
171-
assert result.text == json.dumps(data)
172-
173-
def test_convert_data_part_no_metadata(self):
174-
"""Test conversion of A2A DataPart with no metadata to text."""
175-
# Arrange
176-
data = {"key": "value", "array": [1, 2, 3]}
177-
a2a_part = a2a_types.Part(root=a2a_types.DataPart(data=data))
178-
179-
# Act
180-
result = convert_a2a_part_to_genai_part(a2a_part)
181-
182-
# Assert
183-
assert result is not None
184-
assert isinstance(result, genai_types.Part)
185-
assert result.text == json.dumps(data)
205+
assert result.inline_data is not None
206+
assert result.inline_data.mime_type == A2A_DATA_PART_TEXT_MIME_TYPE
207+
assert result.inline_data.data.startswith(A2A_DATA_PART_START_TAG)
208+
assert result.inline_data.data.endswith(A2A_DATA_PART_END_TAG)
209+
converted_data_part = a2a_types.DataPart.model_validate_json(
210+
result.inline_data.data[
211+
len(A2A_DATA_PART_START_TAG) : -len(A2A_DATA_PART_END_TAG)
212+
]
213+
)
214+
assert converted_data_part.data == data
215+
assert converted_data_part.metadata == metadata
186216

187217
def test_convert_unsupported_file_type(self):
188218
"""Test handling of unsupported file types."""
@@ -325,6 +355,32 @@ def test_convert_inline_data_part_with_video_metadata(self):
325355
assert result.root.metadata is not None
326356
assert _get_adk_metadata_key("video_metadata") in result.root.metadata
327357

358+
def test_convert_inline_data_part_to_data_part(self):
359+
"""Test conversion of GenAI inline_data Part to A2A DataPart."""
360+
# Arrange
361+
data = {"key": "value"}
362+
metadata = {"meta": "data"}
363+
a2a_part_to_convert = a2a_types.DataPart(data=data, metadata=metadata)
364+
json_data = a2a_part_to_convert.model_dump_json(
365+
by_alias=True, exclude_none=True
366+
).encode("utf-8")
367+
genai_part = genai_types.Part(
368+
inline_data=genai_types.Blob(
369+
data=A2A_DATA_PART_START_TAG + json_data + A2A_DATA_PART_END_TAG,
370+
mime_type=A2A_DATA_PART_TEXT_MIME_TYPE,
371+
)
372+
)
373+
374+
# Act
375+
result = convert_genai_part_to_a2a_part(genai_part)
376+
377+
# Assert
378+
assert result is not None
379+
assert isinstance(result, a2a_types.Part)
380+
assert isinstance(result.root, a2a_types.DataPart)
381+
assert result.root.data == data
382+
assert result.root.metadata == metadata
383+
328384
def test_convert_function_call_part(self):
329385
"""Test conversion of GenAI function_call Part to A2A Part."""
330386
# Arrange
@@ -596,6 +652,47 @@ def test_executable_code_round_trip(self):
596652
)
597653
assert result_genai_part.executable_code.code == executable_code.code
598654

655+
def test_data_part_round_trip(self):
656+
"""Test round-trip conversion for data parts."""
657+
# Arrange
658+
data = {"key": "value"}
659+
metadata = {"meta": "data"}
660+
a2a_part = a2a_types.Part(
661+
root=a2a_types.DataPart(data=data, metadata=metadata)
662+
)
663+
664+
# Act
665+
genai_part = convert_a2a_part_to_genai_part(a2a_part)
666+
result_a2a_part = convert_genai_part_to_a2a_part(genai_part)
667+
668+
# Assert
669+
assert result_a2a_part is not None
670+
assert isinstance(result_a2a_part, a2a_types.Part)
671+
assert isinstance(result_a2a_part.root, a2a_types.DataPart)
672+
assert result_a2a_part.root.data == data
673+
assert result_a2a_part.root.metadata == metadata
674+
675+
def test_data_part_with_mime_type_metadata_round_trip(self):
676+
"""Test round-trip conversion for data parts with 'mime_type' in metadata."""
677+
# Arrange
678+
data = {"content": "some data"}
679+
metadata = {"meta": "data", "mime_type": "application/json"}
680+
a2a_part = a2a_types.Part(
681+
root=a2a_types.DataPart(data=data, metadata=metadata)
682+
)
683+
684+
# Act
685+
genai_part = convert_a2a_part_to_genai_part(a2a_part)
686+
result_a2a_part = convert_genai_part_to_a2a_part(genai_part)
687+
688+
# Assert
689+
assert result_a2a_part is not None
690+
assert isinstance(result_a2a_part, a2a_types.Part)
691+
assert isinstance(result_a2a_part.root, a2a_types.DataPart)
692+
assert result_a2a_part.root.data == data
693+
# The 'mime_type' key in the metadata should be preserved as is
694+
assert result_a2a_part.root.metadata == metadata
695+
599696

600697
class TestEdgeCases:
601698
"""Test cases for edge cases and error conditions."""
@@ -612,6 +709,37 @@ def test_empty_text_part(self):
612709
assert result is not None
613710
assert result.text == ""
614711

712+
def test_genai_inline_data_with_mimetype_to_a2a(self):
713+
"""Test conversion of GenAI inline_data with 'mimeType' in DataPart metadata to A2A.
714+
715+
This tests if 'mimeType' in metadata of a DataPart wrapped in inline_data
716+
is correctly handled, ensuring the key casing is preserved.
717+
"""
718+
# Arrange
719+
data = {"key": "value"}
720+
metadata = {"adk_type": "some_type", "mimeType": "image/png"}
721+
a2a_part_inner = a2a_types.DataPart(data=data, metadata=metadata)
722+
json_data = a2a_part_inner.model_dump_json(
723+
by_alias=True, exclude_none=True
724+
).encode("utf-8")
725+
genai_part = genai_types.Part(
726+
inline_data=genai_types.Blob(
727+
data=A2A_DATA_PART_START_TAG + json_data + A2A_DATA_PART_END_TAG,
728+
mime_type=A2A_DATA_PART_TEXT_MIME_TYPE,
729+
)
730+
)
731+
732+
# Act
733+
result = convert_genai_part_to_a2a_part(genai_part)
734+
735+
# Assert
736+
assert result is not None
737+
assert isinstance(result, a2a_types.Part)
738+
assert isinstance(result.root, a2a_types.DataPart)
739+
assert result.root.data == data
740+
# The key casing should be preserved from the JSON
741+
assert result.root.metadata == metadata
742+
615743
def test_none_input_a2a_to_genai(self):
616744
"""Test handling of None input for A2A to GenAI conversion."""
617745
# This test depends on how the function handles None input
@@ -626,39 +754,6 @@ def test_none_input_genai_to_a2a(self):
626754
with pytest.raises(AttributeError):
627755
convert_genai_part_to_a2a_part(None)
628756

629-
def test_data_part_with_complex_data(self):
630-
"""Test conversion of DataPart with complex nested data."""
631-
# Arrange
632-
complex_data = {
633-
"nested": {
634-
"array": [1, 2, {"inner": "value"}],
635-
"boolean": True,
636-
"null_value": None,
637-
},
638-
"unicode": "Hello 世界 🌍",
639-
}
640-
a2a_part = a2a_types.Part(root=a2a_types.DataPart(data=complex_data))
641-
642-
# Act
643-
result = convert_a2a_part_to_genai_part(a2a_part)
644-
645-
# Assert
646-
assert result is not None
647-
assert result.text == json.dumps(complex_data)
648-
649-
def test_data_part_with_empty_metadata(self):
650-
"""Test conversion of DataPart with empty metadata dict."""
651-
# Arrange
652-
data = {"key": "value"}
653-
a2a_part = a2a_types.Part(root=a2a_types.DataPart(data=data, metadata={}))
654-
655-
# Act
656-
result = convert_a2a_part_to_genai_part(a2a_part)
657-
658-
# Assert
659-
assert result is not None
660-
assert result.text == json.dumps(data)
661-
662757

663758
class TestNewConstants:
664759
"""Test cases for new constants and functionality."""

0 commit comments

Comments
 (0)