Skip to content

Commit 95ac650

Browse files
fix(a2a): base64 decode byte data before placing in ContentBlocks (#1195)
1 parent b4efc9d commit 95ac650

File tree

3 files changed

+169
-43
lines changed

3 files changed

+169
-43
lines changed

src/strands/multiagent/a2a/executor.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
streamed requests to the A2AServer.
99
"""
1010

11+
import base64
1112
import json
1213
import logging
1314
import mimetypes
@@ -274,12 +275,18 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten
274275
uri_data = getattr(file_obj, "uri", None)
275276

276277
if bytes_data:
278+
try:
279+
# A2A bytes are always base64-encoded strings
280+
decoded_bytes = base64.b64decode(bytes_data)
281+
except Exception as e:
282+
raise ValueError(f"Failed to decode base64 data for file '{raw_file_name}': {e}") from e
283+
277284
if file_type == "image":
278285
content_blocks.append(
279286
ContentBlock(
280287
image=ImageContent(
281288
format=file_format, # type: ignore
282-
source=ImageSource(bytes=bytes_data),
289+
source=ImageSource(bytes=decoded_bytes),
283290
)
284291
)
285292
)
@@ -288,7 +295,7 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten
288295
ContentBlock(
289296
video=VideoContent(
290297
format=file_format, # type: ignore
291-
source=VideoSource(bytes=bytes_data),
298+
source=VideoSource(bytes=decoded_bytes),
292299
)
293300
)
294301
)
@@ -298,7 +305,7 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten
298305
document=DocumentContent(
299306
format=file_format, # type: ignore
300307
name=file_name,
301-
source=DocumentSource(bytes=bytes_data),
308+
source=DocumentSource(bytes=decoded_bytes),
302309
)
303310
)
304311
)

tests/strands/multiagent/a2a/test_executor.py

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"""Tests for the StrandsA2AExecutor class."""
22

3+
import base64
34
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
6-
from a2a.types import InternalError, UnsupportedOperationError
7+
from a2a.types import DataPart, FilePart, InternalError, TextPart, UnsupportedOperationError
78
from a2a.utils.errors import ServerError
89

910
from strands.agent.agent_result import AgentResult as SAAgentResult
1011
from strands.multiagent.a2a.executor import StrandsA2AExecutor
1112
from strands.types.content import ContentBlock
1213

14+
# Test data constants
15+
VALID_PNG_BYTES = b"fake_png_data"
16+
VALID_MP4_BYTES = b"fake_mp4_data"
17+
VALID_DOCUMENT_BYTES = b"fake_document_data"
18+
1319

1420
def test_executor_initialization(mock_strands_agent):
1521
"""Test that StrandsA2AExecutor initializes correctly."""
@@ -96,18 +102,15 @@ def test_convert_a2a_parts_to_content_blocks_text_part():
96102

97103
def test_convert_a2a_parts_to_content_blocks_file_part_image_bytes():
98104
"""Test conversion of FilePart with image bytes to ContentBlock."""
99-
from a2a.types import FilePart
100-
101105
executor = StrandsA2AExecutor(MagicMock())
102106

103-
# Create test image bytes (no base64 encoding needed)
104-
test_bytes = b"fake_image_data"
107+
base64_bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8")
105108

106109
# Mock file object
107110
file_obj = MagicMock()
108-
file_obj.name = "test_image.jpeg"
109-
file_obj.mime_type = "image/jpeg"
110-
file_obj.bytes = test_bytes
111+
file_obj.name = "test_image.png"
112+
file_obj.mime_type = "image/png"
113+
file_obj.bytes = base64_bytes
111114
file_obj.uri = None
112115

113116
# Mock FilePart with proper spec
@@ -123,24 +126,21 @@ def test_convert_a2a_parts_to_content_blocks_file_part_image_bytes():
123126
assert len(result) == 1
124127
content_block = result[0]
125128
assert "image" in content_block
126-
assert content_block["image"]["format"] == "jpeg"
127-
assert content_block["image"]["source"]["bytes"] == test_bytes
129+
assert content_block["image"]["format"] == "png"
130+
assert content_block["image"]["source"]["bytes"] == VALID_PNG_BYTES
128131

129132

130133
def test_convert_a2a_parts_to_content_blocks_file_part_video_bytes():
131134
"""Test conversion of FilePart with video bytes to ContentBlock."""
132-
from a2a.types import FilePart
133-
134135
executor = StrandsA2AExecutor(MagicMock())
135136

136-
# Create test video bytes (no base64 encoding needed)
137-
test_bytes = b"fake_video_data"
137+
base64_bytes = base64.b64encode(VALID_MP4_BYTES).decode("utf-8")
138138

139139
# Mock file object
140140
file_obj = MagicMock()
141141
file_obj.name = "test_video.mp4"
142142
file_obj.mime_type = "video/mp4"
143-
file_obj.bytes = test_bytes
143+
file_obj.bytes = base64_bytes
144144
file_obj.uri = None
145145

146146
# Mock FilePart with proper spec
@@ -157,23 +157,20 @@ def test_convert_a2a_parts_to_content_blocks_file_part_video_bytes():
157157
content_block = result[0]
158158
assert "video" in content_block
159159
assert content_block["video"]["format"] == "mp4"
160-
assert content_block["video"]["source"]["bytes"] == test_bytes
160+
assert content_block["video"]["source"]["bytes"] == VALID_MP4_BYTES
161161

162162

163163
def test_convert_a2a_parts_to_content_blocks_file_part_document_bytes():
164164
"""Test conversion of FilePart with document bytes to ContentBlock."""
165-
from a2a.types import FilePart
166-
167165
executor = StrandsA2AExecutor(MagicMock())
168166

169-
# Create test document bytes (no base64 encoding needed)
170-
test_bytes = b"fake_document_data"
167+
base64_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8")
171168

172169
# Mock file object
173170
file_obj = MagicMock()
174171
file_obj.name = "test_document.pdf"
175172
file_obj.mime_type = "application/pdf"
176-
file_obj.bytes = test_bytes
173+
file_obj.bytes = base64_bytes
177174
file_obj.uri = None
178175

179176
# Mock FilePart with proper spec
@@ -191,7 +188,7 @@ def test_convert_a2a_parts_to_content_blocks_file_part_document_bytes():
191188
assert "document" in content_block
192189
assert content_block["document"]["format"] == "pdf"
193190
assert content_block["document"]["name"] == "test_document"
194-
assert content_block["document"]["source"]["bytes"] == test_bytes
191+
assert content_block["document"]["source"]["bytes"] == VALID_DOCUMENT_BYTES
195192

196193

197194
def test_convert_a2a_parts_to_content_blocks_file_part_uri():
@@ -226,15 +223,15 @@ def test_convert_a2a_parts_to_content_blocks_file_part_uri():
226223

227224
def test_convert_a2a_parts_to_content_blocks_file_part_with_bytes():
228225
"""Test conversion of FilePart with bytes data."""
229-
from a2a.types import FilePart
230-
231226
executor = StrandsA2AExecutor(MagicMock())
232227

228+
base64_bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8")
229+
233230
# Mock file object with bytes (no validation needed since no decoding)
234231
file_obj = MagicMock()
235232
file_obj.name = "test_image.png"
236233
file_obj.mime_type = "image/png"
237-
file_obj.bytes = b"some_binary_data"
234+
file_obj.bytes = base64_bytes
238235
file_obj.uri = None
239236

240237
# Mock FilePart with proper spec
@@ -250,7 +247,34 @@ def test_convert_a2a_parts_to_content_blocks_file_part_with_bytes():
250247
assert len(result) == 1
251248
content_block = result[0]
252249
assert "image" in content_block
253-
assert content_block["image"]["source"]["bytes"] == b"some_binary_data"
250+
assert content_block["image"]["source"]["bytes"] == VALID_PNG_BYTES
251+
252+
253+
def test_convert_a2a_parts_to_content_blocks_file_part_invalid_base64():
254+
"""Test conversion of FilePart with invalid base64 data raises ValueError."""
255+
executor = StrandsA2AExecutor(MagicMock())
256+
257+
# Invalid base64 string - contains invalid characters
258+
invalid_base64 = "SGVsbG8gV29ybGQ@#$%"
259+
260+
# Mock file object with invalid base64 bytes
261+
file_obj = MagicMock()
262+
file_obj.name = "test.txt"
263+
file_obj.mime_type = "text/plain"
264+
file_obj.bytes = invalid_base64
265+
file_obj.uri = None
266+
267+
# Mock FilePart
268+
file_part = MagicMock(spec=FilePart)
269+
file_part.file = file_obj
270+
part = MagicMock()
271+
part.root = file_part
272+
273+
# Should handle the base64 decode error gracefully and return empty list
274+
result = executor._convert_a2a_parts_to_content_blocks([part])
275+
assert isinstance(result, list)
276+
# The part should be skipped due to base64 decode error
277+
assert len(result) == 0
254278

255279

256280
def test_convert_a2a_parts_to_content_blocks_data_part():
@@ -704,15 +728,15 @@ def test_convert_a2a_parts_to_content_blocks_empty_list():
704728

705729
def test_convert_a2a_parts_to_content_blocks_file_part_no_name():
706730
"""Test conversion of FilePart with no file name."""
707-
from a2a.types import FilePart
708-
709731
executor = StrandsA2AExecutor(MagicMock())
710732

733+
base64_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8")
734+
711735
# Mock file object without name
712736
file_obj = MagicMock()
713737
delattr(file_obj, "name") # Remove name attribute
714738
file_obj.mime_type = "text/plain"
715-
file_obj.bytes = b"test content"
739+
file_obj.bytes = base64_bytes
716740
file_obj.uri = None
717741

718742
# Mock FilePart with proper spec
@@ -733,15 +757,15 @@ def test_convert_a2a_parts_to_content_blocks_file_part_no_name():
733757

734758
def test_convert_a2a_parts_to_content_blocks_file_part_no_mime_type():
735759
"""Test conversion of FilePart with no MIME type."""
736-
from a2a.types import FilePart
737-
738760
executor = StrandsA2AExecutor(MagicMock())
739761

762+
base64_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8")
763+
740764
# Mock file object without MIME type
741765
file_obj = MagicMock()
742766
file_obj.name = "test_file"
743767
delattr(file_obj, "mime_type")
744-
file_obj.bytes = b"test content"
768+
file_obj.bytes = base64_bytes
745769
file_obj.uri = None
746770

747771
# Mock FilePart with proper spec
@@ -837,7 +861,6 @@ async def test_execute_streaming_mode_raises_error_for_empty_content_blocks(
837861
@pytest.mark.asyncio
838862
async def test_execute_with_mixed_part_types(mock_strands_agent, mock_request_context, mock_event_queue):
839863
"""Test execute with a message containing mixed A2A part types."""
840-
from a2a.types import DataPart, FilePart, TextPart
841864

842865
async def mock_stream(content_blocks):
843866
"""Mock streaming function."""
@@ -866,7 +889,7 @@ async def mock_stream(content_blocks):
866889
file_obj = MagicMock()
867890
file_obj.name = "image.png"
868891
file_obj.mime_type = "image/png"
869-
file_obj.bytes = b"fake_image"
892+
file_obj.bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8")
870893
file_obj.uri = None
871894
file_part = MagicMock(spec=FilePart)
872895
file_part.file = file_obj
@@ -907,8 +930,6 @@ def test_integration_example():
907930
908931
This test serves as documentation for the conversion functionality.
909932
"""
910-
from a2a.types import DataPart, FilePart, TextPart
911-
912933
executor = StrandsA2AExecutor(MagicMock())
913934

914935
# Example 1: Text content
@@ -918,7 +939,7 @@ def test_integration_example():
918939
text_part_mock.root = text_part
919940

920941
# Example 2: Image file
921-
image_bytes = b"fake_image_content"
942+
image_bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8")
922943
image_file = MagicMock()
923944
image_file.name = "photo.jpg"
924945
image_file.mime_type = "image/jpeg"
@@ -931,7 +952,7 @@ def test_integration_example():
931952
image_part_mock.root = image_part
932953

933954
# Example 3: Document file
934-
doc_bytes = b"PDF document content"
955+
doc_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8")
935956
doc_file = MagicMock()
936957
doc_file.name = "report.pdf"
937958
doc_file.mime_type = "application/pdf"
@@ -962,13 +983,13 @@ def test_integration_example():
962983
# Image part becomes image ContentBlock with proper format and bytes
963984
assert "image" in content_blocks[1]
964985
assert content_blocks[1]["image"]["format"] == "jpeg"
965-
assert content_blocks[1]["image"]["source"]["bytes"] == image_bytes
986+
assert content_blocks[1]["image"]["source"]["bytes"] == VALID_PNG_BYTES
966987

967988
# Document part becomes document ContentBlock
968989
assert "document" in content_blocks[2]
969990
assert content_blocks[2]["document"]["format"] == "pdf"
970991
assert content_blocks[2]["document"]["name"] == "report" # Extension stripped
971-
assert content_blocks[2]["document"]["source"]["bytes"] == doc_bytes
992+
assert content_blocks[2]["document"]["source"]["bytes"] == VALID_DOCUMENT_BYTES
972993

973994
# Data part becomes text ContentBlock with JSON representation
974995
assert "text" in content_blocks[3]

0 commit comments

Comments
 (0)