Skip to content

Commit 07aa1e0

Browse files
he-yufengGWeale
authored andcommitted
fix(live): keep streaming tool yields from completing turns
Fixes #5947. Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#6009 from he-yufeng:fix/live-stream-turn-complete f72e0b5 PiperOrigin-RevId: 943418613
1 parent cf91b84 commit 07aa1e0

8 files changed

Lines changed: 103 additions & 10 deletions

File tree

src/google/adk/agents/live_request_queue.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ class LiveRequest(BaseModel):
5555
close: bool = False
5656
"""If set, close the queue. queue.shutdown() is only supported in Python 3.13+."""
5757

58+
partial: bool = False
59+
"""If set, the content is a partial turn update that does not complete the current model turn."""
60+
5861

5962
class LiveRequestQueue:
6063
"""Queue used to send LiveRequest in a live(bidirectional streaming) way."""
@@ -65,8 +68,8 @@ def __init__(self):
6568
def close(self):
6669
self._queue.put_nowait(LiveRequest(close=True))
6770

68-
def send_content(self, content: types.Content):
69-
self._queue.put_nowait(LiveRequest(content=content))
71+
def send_content(self, content: types.Content, partial: bool = False):
72+
self._queue.put_nowait(LiveRequest(content=content, partial=partial))
7073

7174
def send_realtime(self, blob: types.Blob):
7275
self._queue.put_nowait(LiveRequest(blob=blob))

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -811,9 +811,9 @@ async def _send_to_model(
811811
is_function_response = content.parts and any(
812812
part.function_response for part in content.parts
813813
)
814-
if not is_function_response:
815-
if not content.role:
816-
content.role = 'user'
814+
if not is_function_response and not content.role:
815+
content.role = 'user'
816+
if not is_function_response and not live_request.partial:
817817
user_content_event = Event(
818818
id=Event.new_id(),
819819
invocation_id=invocation_context.invocation_id,
@@ -824,7 +824,9 @@ async def _send_to_model(
824824
session=invocation_context.session,
825825
event=user_content_event,
826826
)
827-
await llm_connection.send_content(live_request.content)
827+
await llm_connection._send_content(
828+
live_request.content, partial=live_request.partial
829+
)
828830

829831
async def _receive_from_model(
830832
self,

src/google/adk/flows/llm_flows/functions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -995,7 +995,9 @@ async def run_tool_and_update_queue(tool, function_args, tool_context):
995995
updated_content = _build_function_response_content(
996996
tool, result, tool_context.function_call_id
997997
)
998-
invocation_context.live_request_queue.send_content(updated_content)
998+
invocation_context.live_request_queue.send_content(
999+
updated_content, partial=True
1000+
)
9991001
except asyncio.CancelledError:
10001002
raise # Re-raise to properly propagate the cancellation
10011003

src/google/adk/models/base_llm_connection.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ async def send_content(self, content: types.Content):
5151
"""
5252
pass
5353

54+
async def _send_content(
55+
self, content: types.Content, *, partial: bool = False
56+
) -> None:
57+
"""Sends content, optionally as a partial (non-turn-completing) update.
58+
59+
The default implementation ignores ``partial`` and completes the turn.
60+
Connections that support turn-based partial updates override this.
61+
62+
Args:
63+
content: The content to send to the model.
64+
partial: Whether this content is a partial turn update that does not
65+
complete the model turn.
66+
"""
67+
await self.send_content(content)
68+
5469
@abstractmethod
5570
async def send_realtime(self, blob: types.Blob):
5671
"""Sends a chunk of audio or a frame of video to the model in realtime.

src/google/adk/models/gemini_llm_connection.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ async def send_content(self, content: types.Content):
104104
Args:
105105
content: The content to send to the model.
106106
"""
107+
await self._send_content(content)
108+
109+
async def _send_content(
110+
self, content: types.Content, *, partial: bool = False
111+
) -> None:
112+
"""Sends content, optionally as a partial (non-turn-completing) update.
113+
114+
Args:
115+
content: The content to send to the model.
116+
partial: Whether this content is a partial turn update that does not
117+
complete the model turn.
118+
"""
107119
assert content.parts
108120
if content.parts[0].function_response:
109121
# All parts have to be function responses.
@@ -115,7 +127,8 @@ async def send_content(self, content: types.Content):
115127
else:
116128
logger.debug('Sending LLM new content %s', content)
117129
if (
118-
self._is_gemini_3_x_live
130+
not partial
131+
and self._is_gemini_3_x_live
119132
and len(content.parts) == 1
120133
and content.parts[0].text
121134
):
@@ -127,7 +140,7 @@ async def send_content(self, content: types.Content):
127140
await self._gemini_session.send(
128141
input=types.LiveClientContent(
129142
turns=[content],
130-
turn_complete=True,
143+
turn_complete=not partial,
131144
)
132145
)
133146

tests/unittests/agents/test_live_request_queue.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ def test_send_content():
4040
mock_put_nowait.assert_called_once_with(LiveRequest(content=content))
4141

4242

43+
def test_send_content_sets_partial():
44+
queue = LiveRequestQueue()
45+
content = MagicMock(spec=types.Content)
46+
47+
with patch.object(queue._queue, "put_nowait") as mock_put_nowait:
48+
queue.send_content(content, partial=True)
49+
mock_put_nowait.assert_called_once_with(
50+
LiveRequest(content=content, partial=True)
51+
)
52+
53+
4354
def test_send_realtime():
4455
queue = LiveRequestQueue()
4556
blob = MagicMock(spec=types.Blob)

tests/unittests/flows/llm_flows/test_base_llm_flow_realtime.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,5 +197,36 @@ async def test_send_to_model_with_text_content(mock_llm_connection):
197197
await flow._send_to_model(mock_llm_connection, invocation_context)
198198

199199
# Verify send_content was called instead of send_realtime
200-
mock_llm_connection.send_content.assert_called_once_with(content)
200+
mock_llm_connection._send_content.assert_called_once_with(
201+
content, partial=False
202+
)
201203
mock_llm_connection.send_realtime.assert_not_called()
204+
205+
206+
@pytest.mark.asyncio
207+
async def test_send_to_model_with_intermediate_text_content(
208+
mock_llm_connection,
209+
):
210+
agent = Agent(name='test_agent', model='mock')
211+
invocation_context = await testing_utils.create_invocation_context(
212+
agent=agent, user_content=''
213+
)
214+
invocation_context.live_request_queue = LiveRequestQueue()
215+
invocation_context.session_service.append_event = mock.AsyncMock()
216+
217+
flow = TestBaseLlmFlow()
218+
219+
content = types.Content(
220+
role='user', parts=[types.Part.from_text(text='progress')]
221+
)
222+
invocation_context.live_request_queue.send(
223+
LiveRequest(content=content, partial=True)
224+
)
225+
invocation_context.live_request_queue.close()
226+
227+
await flow._send_to_model(mock_llm_connection, invocation_context)
228+
229+
mock_llm_connection._send_content.assert_called_once_with(
230+
content, partial=True
231+
)
232+
invocation_context.session_service.append_event.assert_not_called()

tests/unittests/models/test_gemini_llm_connection.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ async def test_send_content_text(gemini_connection, mock_gemini_session):
124124
assert call_args['input'].turn_complete is True
125125

126126

127+
@pytest.mark.asyncio
128+
async def test_send_content_text_can_keep_turn_open(
129+
gemini_connection, mock_gemini_session
130+
):
131+
content = types.Content(
132+
role='user', parts=[types.Part.from_text(text='progress')]
133+
)
134+
135+
await gemini_connection._send_content(content, partial=True)
136+
137+
mock_gemini_session.send.assert_called_once()
138+
call_args = mock_gemini_session.send.call_args[1]
139+
assert call_args['input'].turns == [content]
140+
assert call_args['input'].turn_complete is False
141+
142+
127143
@pytest.mark.asyncio
128144
async def test_send_content_function_response(
129145
gemini_connection, mock_gemini_session

0 commit comments

Comments
 (0)