Skip to content

Commit 2010569

Browse files
xuanyang15copybara-github
authored andcommitted
fix: Preserve thought_signature in function call conversions for interactions API integration
Related: #4311 Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 868340444
1 parent 7af1858 commit 2010569

File tree

2 files changed

+219
-6
lines changed

2 files changed

+219
-6
lines changed

src/google/adk/models/interactions_utils.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,17 @@ def convert_part_to_interaction_content(part: types.Part) -> Optional[dict]:
6969
if part.text is not None:
7070
return {'type': 'text', 'text': part.text}
7171
elif part.function_call is not None:
72-
return {
72+
result: dict[str, Any] = {
7373
'type': 'function_call',
7474
'id': part.function_call.id or '',
7575
'name': part.function_call.name,
7676
'arguments': part.function_call.args or {},
7777
}
78+
if part.thought_signature is not None:
79+
result['thought_signature'] = base64.b64encode(
80+
part.thought_signature
81+
).decode('utf-8')
82+
return result
7883
elif part.function_response is not None:
7984
# Convert the function response to a string for the interactions API
8085
# The interactions API expects result to be either a string or items list
@@ -306,12 +311,18 @@ def convert_interaction_output_to_part(output: Output) -> Optional[types.Part]:
306311
output.name,
307312
output.id,
308313
)
314+
thought_signature = None
315+
thought_sig_value = getattr(output, 'thought_signature', None)
316+
if thought_sig_value and isinstance(thought_sig_value, str):
317+
# Decode base64 string back to bytes
318+
thought_signature = base64.b64decode(thought_sig_value)
309319
return types.Part(
310320
function_call=types.FunctionCall(
311321
id=output.id,
312322
name=output.name,
313323
args=output.arguments or {},
314-
)
324+
),
325+
thought_signature=thought_signature,
315326
)
316327
elif output_type == 'function_result':
317328
result = output.result
@@ -503,12 +514,18 @@ def convert_interaction_event_to_llm_response(
503514
# the correct interaction_id. If we yield here, interaction_id may be
504515
# None because SSE streams the id later in the 'interaction' event.
505516
if delta.name:
517+
thought_signature = None
518+
thought_sig_value = getattr(delta, 'thought_signature', None)
519+
if thought_sig_value and isinstance(thought_sig_value, str):
520+
# Decode base64 string back to bytes
521+
thought_signature = base64.b64decode(thought_sig_value)
506522
part = types.Part(
507523
function_call=types.FunctionCall(
508524
id=delta.id or '',
509525
name=delta.name,
510526
args=delta.arguments or {},
511-
)
527+
),
528+
thought_signature=thought_signature,
512529
)
513530
aggregated_parts.append(part)
514531
# Return None - function_call will be in the final aggregated response

tests/unittests/models/test_interactions_utils.py

Lines changed: 199 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414

1515
"""Tests for interactions_utils.py conversion functions."""
1616

17+
import base64
1718
import json
1819
from unittest.mock import MagicMock
1920

2021
from google.adk.models import interactions_utils
2122
from google.adk.models.llm_request import LlmRequest
2223
from google.genai import types
23-
import pytest
2424

2525

2626
class TestConvertPartToInteractionContent:
@@ -61,6 +61,42 @@ def test_function_call_part_no_id(self):
6161
assert result['id'] == ''
6262
assert result['name'] == 'get_weather'
6363

64+
def test_function_call_part_with_thought_signature(self):
65+
"""Test converting a function call Part with thought_signature."""
66+
part = types.Part(
67+
function_call=types.FunctionCall(
68+
id='call_456',
69+
name='my_tool',
70+
args={'doc': 'content'},
71+
),
72+
thought_signature=b'test_signature_bytes',
73+
)
74+
result = interactions_utils.convert_part_to_interaction_content(part)
75+
assert result['type'] == 'function_call'
76+
assert result['id'] == 'call_456'
77+
assert result['name'] == 'my_tool'
78+
assert result['arguments'] == {'doc': 'content'}
79+
# thought_signature should be base64 encoded
80+
assert 'thought_signature' in result
81+
82+
assert (
83+
base64.b64decode(result['thought_signature']) == b'test_signature_bytes'
84+
)
85+
86+
def test_function_call_part_without_thought_signature(self):
87+
"""Test converting a function call Part without thought_signature."""
88+
part = types.Part(
89+
function_call=types.FunctionCall(
90+
id='call_789',
91+
name='other_tool',
92+
args={},
93+
)
94+
)
95+
result = interactions_utils.convert_part_to_interaction_content(part)
96+
assert result['type'] == 'function_call'
97+
# thought_signature should not be present
98+
assert 'thought_signature' not in result
99+
64100
def test_function_response_dict(self):
65101
"""Test converting a function response Part with dict response."""
66102
part = types.Part(
@@ -183,8 +219,6 @@ def test_text_with_thought_flag(self):
183219

184220
def test_thought_only_part(self):
185221
"""Test converting a thought-only Part with signature."""
186-
import base64
187-
188222
signature_bytes = b'test-thought-signature'
189223
part = types.Part(thought=True, thought_signature=signature_bytes)
190224
result = interactions_utils.convert_part_to_interaction_content(part)
@@ -443,6 +477,39 @@ def test_function_call_output(self):
443477
assert result.function_call.name == 'get_weather'
444478
assert result.function_call.args == {'city': 'London'}
445479

480+
def test_function_call_output_with_thought_signature(self):
481+
"""Test converting function call output with thought_signature."""
482+
output = MagicMock(
483+
spec=['type', 'id', 'name', 'arguments', 'thought_signature']
484+
)
485+
output.type = 'function_call'
486+
output.id = 'call_sig_123'
487+
output.name = 'gemini3_tool'
488+
output.arguments = {'content': 'hello'}
489+
# thought_signature is base64 encoded in the output
490+
output.thought_signature = base64.b64encode(b'gemini3_signature').decode(
491+
'utf-8'
492+
)
493+
result = interactions_utils.convert_interaction_output_to_part(output)
494+
assert result.function_call.id == 'call_sig_123'
495+
assert result.function_call.name == 'gemini3_tool'
496+
assert result.function_call.args == {'content': 'hello'}
497+
# thought_signature should be decoded back to bytes
498+
assert result.thought_signature == b'gemini3_signature'
499+
500+
def test_function_call_output_without_thought_signature(self):
501+
"""Test converting function call output without thought_signature."""
502+
output = MagicMock(spec=['type', 'id', 'name', 'arguments'])
503+
output.type = 'function_call'
504+
output.id = 'call_no_sig'
505+
output.name = 'regular_tool'
506+
output.arguments = {}
507+
result = interactions_utils.convert_interaction_output_to_part(output)
508+
assert result.function_call.id == 'call_no_sig'
509+
assert result.function_call.name == 'regular_tool'
510+
# thought_signature should be None
511+
assert result.thought_signature is None
512+
446513
def test_function_result_output_with_items_list(self):
447514
"""Test converting function result output with items list.
448515
@@ -759,3 +826,132 @@ def test_full_conversation(self):
759826
assert len(result) == 2
760827
assert result[0].parts[0].text == 'Great'
761828
assert result[1].parts[0].text == 'Tell me more'
829+
830+
831+
class TestConvertInteractionEventToLlmResponse:
832+
"""Tests for convert_interaction_event_to_llm_response."""
833+
834+
def test_text_delta_event(self):
835+
"""Test converting a text delta event."""
836+
event = MagicMock()
837+
event.event_type = 'content.delta'
838+
event.delta = MagicMock()
839+
event.delta.type = 'text'
840+
event.delta.text = 'Hello world'
841+
842+
aggregated_parts = []
843+
result = interactions_utils.convert_interaction_event_to_llm_response(
844+
event, aggregated_parts, interaction_id='int_123'
845+
)
846+
847+
assert result is not None
848+
assert result.partial
849+
assert result.content.parts[0].text == 'Hello world'
850+
assert result.interaction_id == 'int_123'
851+
assert len(aggregated_parts) == 1
852+
853+
def test_function_call_delta_with_thought_signature(self):
854+
"""Test converting a function call delta with thought_signature."""
855+
event = MagicMock()
856+
event.event_type = 'content.delta'
857+
event.delta = MagicMock(
858+
spec=['type', 'id', 'name', 'arguments', 'thought_signature']
859+
)
860+
event.delta.type = 'function_call'
861+
event.delta.id = 'fc_delta_123'
862+
event.delta.name = 'streaming_tool'
863+
event.delta.arguments = {'param': 'value'}
864+
# thought_signature is base64 encoded in the delta
865+
event.delta.thought_signature = base64.b64encode(b'delta_signature').decode(
866+
'utf-8'
867+
)
868+
869+
aggregated_parts = []
870+
result = interactions_utils.convert_interaction_event_to_llm_response(
871+
event, aggregated_parts, interaction_id='int_456'
872+
)
873+
874+
# Function calls return None (added to aggregated_parts only)
875+
assert result is None
876+
assert len(aggregated_parts) == 1
877+
fc_part = aggregated_parts[0]
878+
assert fc_part.function_call.id == 'fc_delta_123'
879+
assert fc_part.function_call.name == 'streaming_tool'
880+
assert fc_part.function_call.args == {'param': 'value'}
881+
# thought_signature should be decoded back to bytes
882+
assert fc_part.thought_signature == b'delta_signature'
883+
884+
def test_function_call_delta_without_thought_signature(self):
885+
"""Test converting a function call delta without thought_signature."""
886+
event = MagicMock()
887+
event.event_type = 'content.delta'
888+
event.delta = MagicMock(spec=['type', 'id', 'name', 'arguments'])
889+
event.delta.type = 'function_call'
890+
event.delta.id = 'fc_no_sig'
891+
event.delta.name = 'regular_tool'
892+
event.delta.arguments = {}
893+
894+
aggregated_parts = []
895+
result = interactions_utils.convert_interaction_event_to_llm_response(
896+
event, aggregated_parts, interaction_id='int_789'
897+
)
898+
899+
# Function calls return None
900+
assert result is None
901+
assert len(aggregated_parts) == 1
902+
fc_part = aggregated_parts[0]
903+
assert fc_part.function_call.name == 'regular_tool'
904+
# thought_signature should be None
905+
assert fc_part.thought_signature is None
906+
907+
def test_function_call_delta_without_name_skipped(self):
908+
"""Test that function call delta without name is skipped."""
909+
event = MagicMock()
910+
event.event_type = 'content.delta'
911+
event.delta = MagicMock(spec=['type', 'id', 'name', 'arguments'])
912+
event.delta.type = 'function_call'
913+
event.delta.id = 'fc_no_name'
914+
event.delta.name = None # No name
915+
event.delta.arguments = {}
916+
917+
aggregated_parts = []
918+
result = interactions_utils.convert_interaction_event_to_llm_response(
919+
event, aggregated_parts, interaction_id='int_000'
920+
)
921+
922+
# Should be skipped (no name)
923+
assert result is None
924+
assert not aggregated_parts
925+
926+
def test_image_delta_with_data(self):
927+
"""Test converting an image delta with inline data."""
928+
event = MagicMock()
929+
event.event_type = 'content.delta'
930+
event.delta = MagicMock()
931+
event.delta.type = 'image'
932+
event.delta.data = b'image_bytes'
933+
event.delta.uri = None
934+
event.delta.mime_type = 'image/png'
935+
936+
aggregated_parts = []
937+
result = interactions_utils.convert_interaction_event_to_llm_response(
938+
event, aggregated_parts, interaction_id='int_img'
939+
)
940+
941+
assert result is not None
942+
assert not result.partial
943+
assert result.content.parts[0].inline_data.data == b'image_bytes'
944+
assert len(aggregated_parts) == 1
945+
946+
def test_unknown_event_type_returns_none(self):
947+
"""Test that unknown event types return None."""
948+
event = MagicMock()
949+
event.event_type = 'some_unknown_event' # Unknown event type
950+
951+
aggregated_parts = []
952+
result = interactions_utils.convert_interaction_event_to_llm_response(
953+
event, aggregated_parts, interaction_id='int_other'
954+
)
955+
956+
assert result is None
957+
assert not aggregated_parts

0 commit comments

Comments
 (0)