Skip to content

Commit f57b05d

Browse files
sandeshveerani4sasha-gitg
authored andcommitted
fix: yield tool_call_parts immediately in live mode to unblock Gemini 3.1 tool calls
Fixes #5407 END_PUBLIC Merge #5408 ## Summary - Tool call parts received via `LiveServerToolCall` in `GeminiLlmConnection.receive()` are now yielded immediately instead of being deferred until `turn_complete` - This unblocks function/tool calling for Gemini 3.1 Flash Live models which do not emit `turn_complete` until they receive the tool response ## Problem Gemini 3.1 Flash Live models send tool calls via `LiveServerToolCall` and wait for the tool response before sending `turn_complete`. The current code accumulates `tool_call_parts` and only yields them on `turn_complete` or loop exit, creating a deadlock where tools are never executed. ## Fix Yield `tool_call_parts` immediately after receiving `message.tool_call`. This is backward-compatible — earlier models that send `turn_complete` after tool calls will still work because the existing yield paths become no-ops when `tool_call_parts` is already empty. ## Test plan - [x] Tested with `gemini-3.1-flash-live-preview` in live mode — tool calls now execute on the first message and the model responds with audio after receiving tool results - [x] Verified no regression with the existing `turn_complete`-based flow Co-authored-by: Sasha Sobran <asobran@google.com> COPYBARA_INTEGRATE_REVIEW=#5408 from sandeshveerani4:fix/live-tool-call-yield ca0e760 PiperOrigin-RevId: 908326192
1 parent 4a786ab commit f57b05d

2 files changed

Lines changed: 62 additions & 0 deletions

File tree

src/google/adk/models/gemini_llm_connection.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,25 @@ async def receive(self) -> AsyncGenerator[LlmResponse, None]:
366366
types.Part(function_call=function_call)
367367
for function_call in message.tool_call.function_calls
368368
])
369+
# Gemini 3.1 does not emit turn_complete until it receives the
370+
# tool response, so yield tool calls immediately to avoid
371+
# deadlocking the conversation. Other models (e.g. 2.5-pro,
372+
# native-audio) send turn_complete after tool calls, so buffer
373+
# and merge them into a single response at turn_complete.
374+
if (
375+
model_name_utils.is_gemini_3_1_flash_live(self._model_version)
376+
and tool_call_parts
377+
):
378+
logger.debug(
379+
'Yielding tool_call_parts immediately for Gemini 3.1 live tool'
380+
' call'
381+
)
382+
yield LlmResponse(
383+
content=types.Content(role='model', parts=tool_call_parts),
384+
model_version=self._model_version,
385+
live_session_id=live_session_id,
386+
)
387+
tool_call_parts = []
369388
if message.session_resumption_update:
370389
logger.debug('Received session resumption message: %s', message)
371390
yield (

tests/unittests/models/test_gemini_llm_connection.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,49 @@ async def mock_receive_generator():
11361136
assert responses[1].turn_complete is True
11371137

11381138

1139+
@pytest.mark.asyncio
1140+
async def test_receive_tool_calls_yielded_immediately_for_gemini_3_1(
1141+
mock_gemini_session,
1142+
):
1143+
"""Test that tool calls are yielded immediately for Gemini 3.1."""
1144+
connection = GeminiLlmConnection(
1145+
mock_gemini_session,
1146+
api_backend=GoogleLLMVariant.VERTEX_AI,
1147+
model_version='gemini-3.1-flash-live-preview',
1148+
)
1149+
1150+
mock_tool_call_msg = mock.create_autospec(
1151+
types.LiveServerMessage, instance=True
1152+
)
1153+
mock_tool_call_msg.usage_metadata = None
1154+
mock_tool_call_msg.server_content = None
1155+
mock_tool_call_msg.session_resumption_update = None
1156+
mock_tool_call_msg.go_away = None
1157+
1158+
function_call = types.FunctionCall(
1159+
name='test_tool',
1160+
args={'arg': 'value'},
1161+
)
1162+
mock_tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True)
1163+
mock_tool_call.function_calls = [function_call]
1164+
mock_tool_call_msg.tool_call = mock_tool_call
1165+
1166+
async def mock_receive_generator():
1167+
yield mock_tool_call_msg
1168+
1169+
receive_mock = mock.Mock(return_value=mock_receive_generator())
1170+
mock_gemini_session.receive = receive_mock
1171+
1172+
responses = []
1173+
async for resp in connection.receive():
1174+
responses.append(resp)
1175+
break
1176+
1177+
assert len(responses) == 1
1178+
assert responses[0].content is not None
1179+
assert responses[0].content.parts[0].function_call.name == 'test_tool'
1180+
1181+
11391182
@pytest.mark.asyncio
11401183
async def test_receive_go_away(gemini_connection, mock_gemini_session):
11411184
"""Test receive yields go_away message."""

0 commit comments

Comments
 (0)