From 2ecc6bed4540114d0ee56bd19cbd6d4c821c3c90 Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Thu, 6 Aug 2026 22:08:19 +0200 Subject: [PATCH 1/2] fix(models): fix multipart text data loss and co-delivered tool_call drop in live receive loop Two independent bugs in GeminiLlmConnection.receive() caused silent data loss in live streaming mode: 1. **Multipart text data loss** (fixes #6616) The receive loop checked `content.parts[0].text` and accumulated only the first part's text: ```python # Before (buggy): if content.parts[0].text: text += content.parts[0].text ``` Any text in `parts[1]`, `parts[2]`, etc. was silently discarded. This affects multimodal streaming responses where a single server message contains multiple text parts. Fix: collect all text parts in the chunk and accumulate each one: ```python # After: _text_parts = [p for p in content.parts if p.text] _has_inline_data = any(p.inline_data for p in content.parts) if _text_parts: for _tp in _text_parts: text += _tp.text ``` The `inline_data` guard in the `elif` branch is also updated to scan all parts rather than only `parts[0]`. 2. **Tool call silently dropped when co-delivered with turn_complete** (fixes #6615) The receive loop contains: ```python async for message in agen: if message.server_content: ... if message.server_content.turn_complete: ... break # exits the async for loop if message.tool_call: # NEVER reached on the same message ... ``` When the Gemini API delivers a `tool_call` in the **same** `LiveServerMessage` as `server_content.turn_complete=True`, the `break` exits the loop before the `if message.tool_call:` block is reached. The tool call was silently dropped, causing the agent to stall waiting for a function result that was never requested. Fix: inspect `message.tool_call` inside the `turn_complete` branch, before the `break`, and append any function calls to `tool_call_parts` so they are yielded by the existing aggregation logic: ```python if message.server_content.turn_complete: if message.tool_call: # handle co-delivered tool call tool_call_parts.extend([...]) ... # existing text flush + tool_call_parts yield break ``` Both fixes are surgical and do not change the behaviour of any path that was already working correctly. --- .../adk/models/gemini_llm_connection.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/google/adk/models/gemini_llm_connection.py b/src/google/adk/models/gemini_llm_connection.py index cc380f229a3..8e5f0fd8faf 100644 --- a/src/google/adk/models/gemini_llm_connection.py +++ b/src/google/adk/models/gemini_llm_connection.py @@ -380,18 +380,26 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]: llm_response.grounding_metadata = ( message.server_content.grounding_metadata ) - if content.parts[0].text: - current_is_thought = getattr(content.parts[0], 'thought', False) + # Collect all text parts from the message chunk. + # Previously only content.parts[0].text was examined, which + # silently dropped text from any part beyond the first when a + # single streaming chunk contained multiple text parts (e.g. a + # multimodal response with several text segments). + _text_parts = [p for p in content.parts if p.text] + _has_inline_data = any(p.inline_data for p in content.parts) + if _text_parts: + current_is_thought = getattr(_text_parts[0], 'thought', False) if text and current_is_thought != is_thought: yield self.__build_full_text_response(text, is_thought) text = '' is_thought = False - text += content.parts[0].text + for _tp in _text_parts: + text += _tp.text is_thought = current_is_thought llm_response.partial = True # don't yield the merged text event when receiving audio data - elif text and not content.parts[0].inline_data: + elif text and not _has_inline_data: yield self.__build_full_text_response( text, is_thought, last_grounding_metadata ) @@ -499,6 +507,26 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]: ) self._output_transcription_text = '' if message.server_content.turn_complete: + # Process any tool_call co-delivered in the same server message as + # turn_complete. Without this, the `break` below exits the receive + # loop before the `if message.tool_call:` block lower in the loop + # body is reached, silently discarding the tool call. + if message.tool_call: + logger.debug( + 'Processing tool_call co-delivered with turn_complete' + ) + if text: + yield self.__build_full_text_response( + text, is_thought, last_grounding_metadata + ) + text = '' + is_thought = False + last_grounding_metadata = None + tool_call_parts.extend([ + types.Part(function_call=function_call) + for function_call in message.tool_call.function_calls or [] + ]) + # Capture final grounding metadata before last_grounding_metadata is cleared in the next block. final_grounding_metadata = ( grounding_metadata From fafc450d8bef370fb67a5c891dfb520e058c7687 Mon Sep 17 00:00:00 2001 From: Patrick Ribbsaeter Date: Sun, 9 Aug 2026 05:22:22 +0200 Subject: [PATCH 2/2] test: cover multipart text and co-delivered tool calls --- .../models/test_gemini_llm_connection.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/unittests/models/test_gemini_llm_connection.py b/tests/unittests/models/test_gemini_llm_connection.py index 3f141af3b9e..233b33ffa8a 100644 --- a/tests/unittests/models/test_gemini_llm_connection.py +++ b/tests/unittests/models/test_gemini_llm_connection.py @@ -2377,3 +2377,73 @@ async def mock_receive_generator(): assert len(responses) == 1 assert responses[0].voice_activity == mock_vad + + +@pytest.mark.asyncio +async def test_receive_preserves_all_text_parts( + gemini_connection, mock_gemini_session +): + """All text parts in one live message are accumulated in order.""" + content = types.Content( + role='model', + parts=[ + types.Part.from_text(text='first '), + types.Part.from_text(text='second'), + ], + ) + content_message = _create_mock_receive_message(model_turn=content) + complete_message = _create_mock_receive_message(turn_complete=True) + + async def mock_receive_generator(): + yield content_message + yield complete_message + + mock_gemini_session.receive = mock.Mock( + return_value=mock_receive_generator() + ) + + responses = [response async for response in gemini_connection.receive()] + full_text = [ + response.content.parts[0].text + for response in responses + if response.content + and response.content.parts + and response.content.parts[0].text + and not response.partial + ] + + assert 'first second' in full_text + + +@pytest.mark.asyncio +async def test_receive_preserves_tool_call_co_delivered_with_turn_complete( + gemini_connection, mock_gemini_session +): + """A tool call on the turn-complete message is yielded before completion.""" + function_call = types.FunctionCall( + name='get_weather', args={'city': 'Amsterdam'} + ) + tool_call = mock.Mock() + tool_call.function_calls = [function_call] + message = _create_mock_receive_message( + turn_complete=True, tool_call=tool_call + ) + + async def mock_receive_generator(): + yield message + + mock_gemini_session.receive = mock.Mock( + return_value=mock_receive_generator() + ) + + responses = [response async for response in gemini_connection.receive()] + tool_response = next( + response + for response in responses + if response.content + and response.content.parts + and response.content.parts[0].function_call + ) + + assert tool_response.content.parts[0].function_call == function_call + assert responses[-1].turn_complete is True