Skip to content

Commit f9c104f

Browse files
xuanyang15copybara-github
authored andcommitted
fix: Preserve thought_signature in FunctionCall conversions between GenAI and A2A
Close: #4311 Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 877465519
1 parent a61c7e3 commit f9c104f

File tree

2 files changed

+229
-8
lines changed

2 files changed

+229
-8
lines changed

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,25 @@ def convert_a2a_part_to_genai_part(
104104
part.metadata[_get_adk_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)]
105105
== A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL
106106
):
107+
# Restore thought_signature if present
108+
thought_signature = None
109+
thought_sig_key = _get_adk_metadata_key('thought_signature')
110+
if thought_sig_key in part.metadata:
111+
sig_value = part.metadata[thought_sig_key]
112+
if isinstance(sig_value, bytes):
113+
thought_signature = sig_value
114+
elif isinstance(sig_value, str):
115+
try:
116+
thought_signature = base64.b64decode(sig_value)
117+
except Exception:
118+
logger.warning(
119+
'Failed to decode thought_signature: %s', sig_value
120+
)
107121
return genai_types.Part(
108122
function_call=genai_types.FunctionCall.model_validate(
109123
part.data, by_alias=True
110-
)
124+
),
125+
thought_signature=thought_signature,
111126
)
112127
if (
113128
part.metadata[_get_adk_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)]
@@ -214,16 +229,22 @@ def convert_genai_part_to_a2a_part(
214229
# TODO once A2A defined how to service such information, migrate below
215230
# logic accordingly
216231
if part.function_call:
232+
fc_metadata = {
233+
_get_adk_metadata_key(
234+
A2A_DATA_PART_METADATA_TYPE_KEY
235+
): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL
236+
}
237+
# Preserve thought_signature if present
238+
if part.thought_signature is not None:
239+
fc_metadata[_get_adk_metadata_key('thought_signature')] = (
240+
base64.b64encode(part.thought_signature).decode('utf-8')
241+
)
217242
return a2a_types.Part(
218243
root=a2a_types.DataPart(
219244
data=part.function_call.model_dump(
220245
by_alias=True, exclude_none=True
221246
),
222-
metadata={
223-
_get_adk_metadata_key(
224-
A2A_DATA_PART_METADATA_TYPE_KEY
225-
): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL
226-
},
247+
metadata=fc_metadata,
227248
)
228249
)
229250

tests/unittests/a2a/converters/test_part_converter.py

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import base64
1516
import json
1617
from unittest.mock import Mock
1718
from unittest.mock import patch
@@ -74,7 +75,6 @@ def test_convert_file_part_with_bytes(self):
7475
# Arrange
7576
test_bytes = b"test file content"
7677
# A2A FileWithBytes expects base64-encoded string
77-
import base64
7878

7979
base64_encoded = base64.b64encode(test_bytes).decode("utf-8")
8080
a2a_part = a2a_types.Part(
@@ -328,7 +328,6 @@ def test_convert_inline_data_part(self):
328328
assert isinstance(result.root, a2a_types.FilePart)
329329
assert isinstance(result.root.file, a2a_types.FileWithBytes)
330330
# A2A FileWithBytes now stores base64-encoded bytes to ensure round-trip compatibility
331-
import base64
332331

333332
expected_base64 = base64.b64encode(test_bytes).decode("utf-8")
334333
assert result.root.file.bytes == expected_base64
@@ -841,3 +840,204 @@ def test_convert_a2a_data_part_with_executable_code_metadata(self):
841840
assert result.executable_code is not None
842841
assert result.executable_code.language == genai_types.Language.PYTHON
843842
assert result.executable_code.code == "print('Hello, World!')"
843+
844+
845+
class TestThoughtSignaturePreservation:
846+
"""Tests for thought_signature preservation in function call conversions."""
847+
848+
def test_genai_function_call_with_thought_signature_to_a2a(self):
849+
"""Test that thought_signature is preserved when converting GenAI to A2A."""
850+
# Arrange
851+
function_call = genai_types.FunctionCall(
852+
id="fc_gemini3",
853+
name="my_tool",
854+
args={"document": "test content"},
855+
)
856+
genai_part = genai_types.Part(
857+
function_call=function_call,
858+
thought_signature=b"gemini3_signature_bytes",
859+
)
860+
861+
# Act
862+
result = convert_genai_part_to_a2a_part(genai_part)
863+
864+
# Assert
865+
assert result is not None
866+
assert isinstance(result.root, a2a_types.DataPart)
867+
assert (
868+
result.root.metadata[
869+
_get_adk_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)
870+
]
871+
== A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL
872+
)
873+
# thought_signature should be base64 encoded in metadata
874+
thought_sig_key = _get_adk_metadata_key("thought_signature")
875+
assert thought_sig_key in result.root.metadata
876+
assert (
877+
base64.b64decode(result.root.metadata[thought_sig_key])
878+
== b"gemini3_signature_bytes"
879+
)
880+
881+
def test_genai_function_call_without_thought_signature_to_a2a(self):
882+
"""Test function call without thought_signature doesn't add metadata key."""
883+
# Arrange
884+
function_call = genai_types.FunctionCall(
885+
id="fc_regular",
886+
name="regular_tool",
887+
args={},
888+
)
889+
genai_part = genai_types.Part(function_call=function_call)
890+
891+
# Act
892+
result = convert_genai_part_to_a2a_part(genai_part)
893+
894+
# Assert
895+
assert result is not None
896+
assert isinstance(result.root, a2a_types.DataPart)
897+
# thought_signature key should not be present
898+
thought_sig_key = _get_adk_metadata_key("thought_signature")
899+
assert thought_sig_key not in result.root.metadata
900+
901+
def test_a2a_function_call_with_thought_signature_to_genai(self):
902+
"""Test that thought_signature is restored when converting A2A to GenAI."""
903+
# Arrange
904+
a2a_part = a2a_types.Part(
905+
root=a2a_types.DataPart(
906+
data={
907+
"id": "fc_gemini3",
908+
"name": "my_tool",
909+
"args": {"document": "test content"},
910+
},
911+
metadata={
912+
_get_adk_metadata_key(
913+
A2A_DATA_PART_METADATA_TYPE_KEY
914+
): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL,
915+
_get_adk_metadata_key("thought_signature"): (
916+
base64.b64encode(b"restored_signature").decode("utf-8")
917+
),
918+
},
919+
)
920+
)
921+
922+
# Act
923+
result = convert_a2a_part_to_genai_part(a2a_part)
924+
925+
# Assert
926+
assert result is not None
927+
assert result.function_call is not None
928+
assert result.function_call.name == "my_tool"
929+
# thought_signature should be decoded back to bytes
930+
assert result.thought_signature == b"restored_signature"
931+
932+
def test_a2a_function_call_without_thought_signature_to_genai(self):
933+
"""Test function call without thought_signature returns None for it."""
934+
# Arrange
935+
a2a_part = a2a_types.Part(
936+
root=a2a_types.DataPart(
937+
data={
938+
"id": "fc_regular",
939+
"name": "regular_tool",
940+
"args": {},
941+
},
942+
metadata={
943+
_get_adk_metadata_key(
944+
A2A_DATA_PART_METADATA_TYPE_KEY
945+
): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL,
946+
},
947+
)
948+
)
949+
950+
# Act
951+
result = convert_a2a_part_to_genai_part(a2a_part)
952+
953+
# Assert
954+
assert result is not None
955+
assert result.function_call is not None
956+
assert result.function_call.name == "regular_tool"
957+
# thought_signature should be None
958+
assert result.thought_signature is None
959+
960+
def test_function_call_with_thought_signature_round_trip(self):
961+
"""Test thought_signature is preserved in GenAI -> A2A -> GenAI round trip."""
962+
# Arrange
963+
original_signature = b"round_trip_signature_test"
964+
function_call = genai_types.FunctionCall(
965+
id="fc_round_trip",
966+
name="round_trip_tool",
967+
args={"key": "value"},
968+
)
969+
original_part = genai_types.Part(
970+
function_call=function_call,
971+
thought_signature=original_signature,
972+
)
973+
974+
# Act - Convert GenAI -> A2A -> GenAI
975+
a2a_part = convert_genai_part_to_a2a_part(original_part)
976+
restored_part = convert_a2a_part_to_genai_part(a2a_part)
977+
978+
# Assert
979+
assert restored_part is not None
980+
assert restored_part.function_call is not None
981+
assert restored_part.function_call.name == "round_trip_tool"
982+
assert restored_part.thought_signature == original_signature
983+
984+
def test_a2a_function_call_with_bytes_thought_signature_to_genai(self):
985+
"""Test that bytes thought_signature is used directly without decoding."""
986+
# Arrange - metadata contains raw bytes (not base64 encoded)
987+
a2a_part = a2a_types.Part(
988+
root=a2a_types.DataPart(
989+
data={
990+
"id": "fc_bytes",
991+
"name": "bytes_tool",
992+
"args": {},
993+
},
994+
metadata={
995+
_get_adk_metadata_key(
996+
A2A_DATA_PART_METADATA_TYPE_KEY
997+
): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL,
998+
_get_adk_metadata_key(
999+
"thought_signature"
1000+
): b"raw_bytes_signature",
1001+
},
1002+
)
1003+
)
1004+
1005+
# Act
1006+
result = convert_a2a_part_to_genai_part(a2a_part)
1007+
1008+
# Assert
1009+
assert result is not None
1010+
assert result.function_call is not None
1011+
# bytes should be used directly
1012+
assert result.thought_signature == b"raw_bytes_signature"
1013+
1014+
def test_a2a_function_call_with_invalid_base64_thought_signature(self):
1015+
"""Test that invalid base64 thought_signature logs warning and returns None."""
1016+
# Arrange - metadata contains invalid base64 string
1017+
a2a_part = a2a_types.Part(
1018+
root=a2a_types.DataPart(
1019+
data={
1020+
"id": "fc_invalid",
1021+
"name": "invalid_sig_tool",
1022+
"args": {},
1023+
},
1024+
metadata={
1025+
_get_adk_metadata_key(
1026+
A2A_DATA_PART_METADATA_TYPE_KEY
1027+
): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL,
1028+
_get_adk_metadata_key(
1029+
"thought_signature"
1030+
): "not_valid_base64!!!",
1031+
},
1032+
)
1033+
)
1034+
1035+
# Act
1036+
result = convert_a2a_part_to_genai_part(a2a_part)
1037+
1038+
# Assert
1039+
assert result is not None
1040+
assert result.function_call is not None
1041+
assert result.function_call.name == "invalid_sig_tool"
1042+
# thought_signature should be None due to decode failure
1043+
assert result.thought_signature is None

0 commit comments

Comments
 (0)