From 20105690100d9c2f69c061ac08be5e94c50dc39c Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Tue, 10 Feb 2026 15:13:48 -0800 Subject: [PATCH] fix: Preserve thought_signature in function call conversions for interactions API integration Related: https://github.com/google/adk-python/issues/4311 Co-authored-by: Xuan Yang PiperOrigin-RevId: 868340444 --- src/google/adk/models/interactions_utils.py | 23 +- .../models/test_interactions_utils.py | 202 +++++++++++++++++- 2 files changed, 219 insertions(+), 6 deletions(-) diff --git a/src/google/adk/models/interactions_utils.py b/src/google/adk/models/interactions_utils.py index 9690abea..add8da0c 100644 --- a/src/google/adk/models/interactions_utils.py +++ b/src/google/adk/models/interactions_utils.py @@ -69,12 +69,17 @@ def convert_part_to_interaction_content(part: types.Part) -> Optional[dict]: if part.text is not None: return {'type': 'text', 'text': part.text} elif part.function_call is not None: - return { + result: dict[str, Any] = { 'type': 'function_call', 'id': part.function_call.id or '', 'name': part.function_call.name, 'arguments': part.function_call.args or {}, } + if part.thought_signature is not None: + result['thought_signature'] = base64.b64encode( + part.thought_signature + ).decode('utf-8') + return result elif part.function_response is not None: # Convert the function response to a string for the interactions API # 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]: output.name, output.id, ) + thought_signature = None + thought_sig_value = getattr(output, 'thought_signature', None) + if thought_sig_value and isinstance(thought_sig_value, str): + # Decode base64 string back to bytes + thought_signature = base64.b64decode(thought_sig_value) return types.Part( function_call=types.FunctionCall( id=output.id, name=output.name, args=output.arguments or {}, - ) + ), + thought_signature=thought_signature, ) elif output_type == 'function_result': result = output.result @@ -503,12 +514,18 @@ def convert_interaction_event_to_llm_response( # the correct interaction_id. If we yield here, interaction_id may be # None because SSE streams the id later in the 'interaction' event. if delta.name: + thought_signature = None + thought_sig_value = getattr(delta, 'thought_signature', None) + if thought_sig_value and isinstance(thought_sig_value, str): + # Decode base64 string back to bytes + thought_signature = base64.b64decode(thought_sig_value) part = types.Part( function_call=types.FunctionCall( id=delta.id or '', name=delta.name, args=delta.arguments or {}, - ) + ), + thought_signature=thought_signature, ) aggregated_parts.append(part) # Return None - function_call will be in the final aggregated response diff --git a/tests/unittests/models/test_interactions_utils.py b/tests/unittests/models/test_interactions_utils.py index e497cd31..93dced0f 100644 --- a/tests/unittests/models/test_interactions_utils.py +++ b/tests/unittests/models/test_interactions_utils.py @@ -14,13 +14,13 @@ """Tests for interactions_utils.py conversion functions.""" +import base64 import json from unittest.mock import MagicMock from google.adk.models import interactions_utils from google.adk.models.llm_request import LlmRequest from google.genai import types -import pytest class TestConvertPartToInteractionContent: @@ -61,6 +61,42 @@ class TestConvertPartToInteractionContent: assert result['id'] == '' assert result['name'] == 'get_weather' + def test_function_call_part_with_thought_signature(self): + """Test converting a function call Part with thought_signature.""" + part = types.Part( + function_call=types.FunctionCall( + id='call_456', + name='my_tool', + args={'doc': 'content'}, + ), + thought_signature=b'test_signature_bytes', + ) + result = interactions_utils.convert_part_to_interaction_content(part) + assert result['type'] == 'function_call' + assert result['id'] == 'call_456' + assert result['name'] == 'my_tool' + assert result['arguments'] == {'doc': 'content'} + # thought_signature should be base64 encoded + assert 'thought_signature' in result + + assert ( + base64.b64decode(result['thought_signature']) == b'test_signature_bytes' + ) + + def test_function_call_part_without_thought_signature(self): + """Test converting a function call Part without thought_signature.""" + part = types.Part( + function_call=types.FunctionCall( + id='call_789', + name='other_tool', + args={}, + ) + ) + result = interactions_utils.convert_part_to_interaction_content(part) + assert result['type'] == 'function_call' + # thought_signature should not be present + assert 'thought_signature' not in result + def test_function_response_dict(self): """Test converting a function response Part with dict response.""" part = types.Part( @@ -183,8 +219,6 @@ class TestConvertPartToInteractionContent: def test_thought_only_part(self): """Test converting a thought-only Part with signature.""" - import base64 - signature_bytes = b'test-thought-signature' part = types.Part(thought=True, thought_signature=signature_bytes) result = interactions_utils.convert_part_to_interaction_content(part) @@ -443,6 +477,39 @@ class TestConvertInteractionOutputToPart: assert result.function_call.name == 'get_weather' assert result.function_call.args == {'city': 'London'} + def test_function_call_output_with_thought_signature(self): + """Test converting function call output with thought_signature.""" + output = MagicMock( + spec=['type', 'id', 'name', 'arguments', 'thought_signature'] + ) + output.type = 'function_call' + output.id = 'call_sig_123' + output.name = 'gemini3_tool' + output.arguments = {'content': 'hello'} + # thought_signature is base64 encoded in the output + output.thought_signature = base64.b64encode(b'gemini3_signature').decode( + 'utf-8' + ) + result = interactions_utils.convert_interaction_output_to_part(output) + assert result.function_call.id == 'call_sig_123' + assert result.function_call.name == 'gemini3_tool' + assert result.function_call.args == {'content': 'hello'} + # thought_signature should be decoded back to bytes + assert result.thought_signature == b'gemini3_signature' + + def test_function_call_output_without_thought_signature(self): + """Test converting function call output without thought_signature.""" + output = MagicMock(spec=['type', 'id', 'name', 'arguments']) + output.type = 'function_call' + output.id = 'call_no_sig' + output.name = 'regular_tool' + output.arguments = {} + result = interactions_utils.convert_interaction_output_to_part(output) + assert result.function_call.id == 'call_no_sig' + assert result.function_call.name == 'regular_tool' + # thought_signature should be None + assert result.thought_signature is None + def test_function_result_output_with_items_list(self): """Test converting function result output with items list. @@ -759,3 +826,132 @@ class TestGetLatestUserContents: assert len(result) == 2 assert result[0].parts[0].text == 'Great' assert result[1].parts[0].text == 'Tell me more' + + +class TestConvertInteractionEventToLlmResponse: + """Tests for convert_interaction_event_to_llm_response.""" + + def test_text_delta_event(self): + """Test converting a text delta event.""" + event = MagicMock() + event.event_type = 'content.delta' + event.delta = MagicMock() + event.delta.type = 'text' + event.delta.text = 'Hello world' + + aggregated_parts = [] + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_123' + ) + + assert result is not None + assert result.partial + assert result.content.parts[0].text == 'Hello world' + assert result.interaction_id == 'int_123' + assert len(aggregated_parts) == 1 + + def test_function_call_delta_with_thought_signature(self): + """Test converting a function call delta with thought_signature.""" + event = MagicMock() + event.event_type = 'content.delta' + event.delta = MagicMock( + spec=['type', 'id', 'name', 'arguments', 'thought_signature'] + ) + event.delta.type = 'function_call' + event.delta.id = 'fc_delta_123' + event.delta.name = 'streaming_tool' + event.delta.arguments = {'param': 'value'} + # thought_signature is base64 encoded in the delta + event.delta.thought_signature = base64.b64encode(b'delta_signature').decode( + 'utf-8' + ) + + aggregated_parts = [] + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_456' + ) + + # Function calls return None (added to aggregated_parts only) + assert result is None + assert len(aggregated_parts) == 1 + fc_part = aggregated_parts[0] + assert fc_part.function_call.id == 'fc_delta_123' + assert fc_part.function_call.name == 'streaming_tool' + assert fc_part.function_call.args == {'param': 'value'} + # thought_signature should be decoded back to bytes + assert fc_part.thought_signature == b'delta_signature' + + def test_function_call_delta_without_thought_signature(self): + """Test converting a function call delta without thought_signature.""" + event = MagicMock() + event.event_type = 'content.delta' + event.delta = MagicMock(spec=['type', 'id', 'name', 'arguments']) + event.delta.type = 'function_call' + event.delta.id = 'fc_no_sig' + event.delta.name = 'regular_tool' + event.delta.arguments = {} + + aggregated_parts = [] + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_789' + ) + + # Function calls return None + assert result is None + assert len(aggregated_parts) == 1 + fc_part = aggregated_parts[0] + assert fc_part.function_call.name == 'regular_tool' + # thought_signature should be None + assert fc_part.thought_signature is None + + def test_function_call_delta_without_name_skipped(self): + """Test that function call delta without name is skipped.""" + event = MagicMock() + event.event_type = 'content.delta' + event.delta = MagicMock(spec=['type', 'id', 'name', 'arguments']) + event.delta.type = 'function_call' + event.delta.id = 'fc_no_name' + event.delta.name = None # No name + event.delta.arguments = {} + + aggregated_parts = [] + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_000' + ) + + # Should be skipped (no name) + assert result is None + assert not aggregated_parts + + def test_image_delta_with_data(self): + """Test converting an image delta with inline data.""" + event = MagicMock() + event.event_type = 'content.delta' + event.delta = MagicMock() + event.delta.type = 'image' + event.delta.data = b'image_bytes' + event.delta.uri = None + event.delta.mime_type = 'image/png' + + aggregated_parts = [] + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_img' + ) + + assert result is not None + assert not result.partial + assert result.content.parts[0].inline_data.data == b'image_bytes' + assert len(aggregated_parts) == 1 + + def test_unknown_event_type_returns_none(self): + """Test that unknown event types return None.""" + event = MagicMock() + event.event_type = 'some_unknown_event' # Unknown event type + + aggregated_parts = [] + result = interactions_utils.convert_interaction_event_to_llm_response( + event, aggregated_parts, interaction_id='int_other' + ) + + assert result is None + assert not aggregated_parts