Skip to content

Commit 8219774

Browse files
GWealecopybara-github
authored andcommitted
feat: add state_delta support to LiveRequest for live mode
The non-live runner path can apply session state changes via a state_delta, but the live/bidi flow offered no equivalent, so live callers could not seed or update session state alongside their input. Add an optional state_delta field to LiveRequest and, in the live flow, apply it as a separate state-delta event so it always takes effect regardless of content, partial, or function-response requests. The field defaults to None, preserving existing behavior. Close #4220 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 951162326
1 parent 6f1efe7 commit 8219774

4 files changed

Lines changed: 191 additions & 7 deletions

File tree

src/google/adk/agents/live_request_queue.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import asyncio
18+
from typing import Any
1819
from typing import Optional
1920

2021
from google.genai import types
@@ -32,32 +33,41 @@ class LiveRequest(BaseModel):
3233
"""If set, send the content to the model in turn-by-turn mode.
3334
3435
When multiple fields are set, they are processed by priority (highest first):
35-
activity_start > activity_end > blob > content.
36+
activity_start > activity_end > blob > content. state_delta, if set, is always
37+
applied regardless of the other fields.
3638
"""
3739
blob: Optional[types.Blob] = None
3840
"""If set, send the blob to the model in realtime mode.
3941
4042
When multiple fields are set, they are processed by priority (highest first):
41-
activity_start > activity_end > blob > content.
43+
activity_start > activity_end > blob > content. state_delta, if set, is always
44+
applied regardless of the other fields.
4245
"""
4346
activity_start: Optional[types.ActivityStart] = None
4447
"""If set, signal the start of user activity to the model.
4548
4649
When multiple fields are set, they are processed by priority (highest first):
47-
activity_start > activity_end > blob > content.
50+
activity_start > activity_end > blob > content. state_delta, if set, is always
51+
applied regardless of the other fields.
4852
"""
4953
activity_end: Optional[types.ActivityEnd] = None
5054
"""If set, signal the end of user activity to the model.
5155
5256
When multiple fields are set, they are processed by priority (highest first):
53-
activity_start > activity_end > blob > content.
57+
activity_start > activity_end > blob > content. state_delta, if set, is always
58+
applied regardless of the other fields.
5459
"""
5560
close: bool = False
5661
"""If set, close the queue. queue.shutdown() is only supported in Python 3.13+."""
5762

5863
partial: bool = False
5964
"""If set, the content is a partial turn update that does not complete the current model turn."""
6065

66+
state_delta: Optional[dict[str, Any]] = None
67+
"""If set, these state changes are applied to the session, so they take
68+
effect even when the request carries no content or a partial/
69+
function-response turn."""
70+
6171

6272
class LiveRequestQueue:
6373
"""Queue used to send LiveRequest in a live(bidirectional streaming) way."""

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ...agents.run_config import StreamingMode
3939
from ...auth.auth_tool import AuthConfig
4040
from ...events.event import Event
41+
from ...events.event_actions import EventActions
4142
from ...models.base_llm_connection import BaseLlmConnection
4243
from ...models.google_llm import Gemini
4344
from ...models.google_llm import GoogleLLMVariant
@@ -795,6 +796,29 @@ async def _send_to_model(
795796
# Yield to event loop for cooperative multitasking
796797
await asyncio.sleep(0)
797798

799+
# State changes ride on the user content event when one is created below;
800+
# otherwise a standalone content-less event applies them.
801+
is_function_response = bool(
802+
live_request.content
803+
and live_request.content.parts
804+
and any(part.function_response for part in live_request.content.parts)
805+
)
806+
content_event_created = bool(
807+
live_request.content
808+
and not live_request.close
809+
and not live_request.partial
810+
and not is_function_response
811+
)
812+
if live_request.state_delta and not content_event_created:
813+
await invocation_context.session_service.append_event(
814+
session=invocation_context.session,
815+
event=Event(
816+
invocation_id=invocation_context.invocation_id,
817+
author='user',
818+
actions=EventActions(state_delta=live_request.state_delta),
819+
),
820+
)
821+
798822
if live_request.close:
799823
await llm_connection.close()
800824
return
@@ -817,9 +841,6 @@ async def _send_to_model(
817841
raise ValueError('User message cannot contain function calls.')
818842
# Persist user text content to session (similar to non-live mode)
819843
# Skip function responses - they are already handled separately
820-
is_function_response = content.parts and any(
821-
part.function_response for part in content.parts
822-
)
823844
if not is_function_response and not content.role:
824845
content.role = 'user'
825846
if not is_function_response and not live_request.partial:
@@ -828,6 +849,9 @@ async def _send_to_model(
828849
invocation_id=invocation_context.invocation_id,
829850
author='user',
830851
content=content,
852+
actions=EventActions(state_delta=live_request.state_delta)
853+
if live_request.state_delta
854+
else EventActions(),
831855
)
832856
await invocation_context.session_service.append_event(
833857
session=invocation_context.session,

tests/unittests/agents/test_live_request_queue.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,13 @@ async def test_get():
7979

8080
assert result == res
8181
mock_get.assert_called_once()
82+
83+
84+
def test_state_delta_defaults_to_none():
85+
assert LiveRequest().state_delta is None
86+
87+
88+
def test_state_delta_json_round_trip():
89+
req = LiveRequest(state_delta={"a": 1})
90+
restored = LiveRequest.model_validate_json(req.model_dump_json())
91+
assert restored.state_delta == {"a": 1}

tests/unittests/flows/llm_flows/test_base_llm_flow_realtime.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,143 @@ async def test_send_to_model_with_intermediate_text_content(
230230
content, partial=True
231231
)
232232
invocation_context.session_service.append_event.assert_not_called()
233+
234+
235+
@pytest.mark.asyncio
236+
async def test_send_to_model_applies_state_delta(mock_llm_connection):
237+
"""Test _send_to_model applies state_delta as a state-delta event."""
238+
agent = Agent(name='test_agent', model='mock')
239+
invocation_context = await testing_utils.create_invocation_context(
240+
agent=agent, user_content=''
241+
)
242+
invocation_context.live_request_queue = LiveRequestQueue()
243+
244+
flow = TestBaseLlmFlow()
245+
246+
invocation_context.live_request_queue.send(
247+
LiveRequest(state_delta={'k': 'v'})
248+
)
249+
invocation_context.live_request_queue.close()
250+
251+
await flow._send_to_model(mock_llm_connection, invocation_context)
252+
253+
assert invocation_context.session.state['k'] == 'v'
254+
mock_llm_connection._send_content.assert_not_called()
255+
256+
257+
@pytest.mark.asyncio
258+
async def test_send_to_model_state_delta_with_content(mock_llm_connection):
259+
"""Test _send_to_model applies state_delta and forwards content together."""
260+
agent = Agent(name='test_agent', model='mock')
261+
invocation_context = await testing_utils.create_invocation_context(
262+
agent=agent, user_content=''
263+
)
264+
invocation_context.live_request_queue = LiveRequestQueue()
265+
266+
flow = TestBaseLlmFlow()
267+
268+
content = types.Content(role='user', parts=[types.Part.from_text(text='hi')])
269+
invocation_context.live_request_queue.send(
270+
LiveRequest(content=content, state_delta={'k': 'v'})
271+
)
272+
invocation_context.live_request_queue.close()
273+
274+
await flow._send_to_model(mock_llm_connection, invocation_context)
275+
276+
assert invocation_context.session.state['k'] == 'v'
277+
# The state delta rides on the single user content event (no extra event).
278+
events = invocation_context.session.events
279+
assert len(events) == 1
280+
assert events[0].content == content
281+
assert events[0].actions.state_delta == {'k': 'v'}
282+
mock_llm_connection._send_content.assert_called_once_with(
283+
content, partial=False
284+
)
285+
286+
287+
@pytest.mark.asyncio
288+
async def test_send_to_model_state_delta_with_partial_content(
289+
mock_llm_connection,
290+
):
291+
"""state_delta applies even when the partial turn skips the content event."""
292+
agent = Agent(name='test_agent', model='mock')
293+
invocation_context = await testing_utils.create_invocation_context(
294+
agent=agent, user_content=''
295+
)
296+
invocation_context.live_request_queue = LiveRequestQueue()
297+
298+
flow = TestBaseLlmFlow()
299+
300+
content = types.Content(
301+
role='user', parts=[types.Part.from_text(text='progress')]
302+
)
303+
invocation_context.live_request_queue.send(
304+
LiveRequest(content=content, state_delta={'k': 'v'}, partial=True)
305+
)
306+
invocation_context.live_request_queue.close()
307+
308+
await flow._send_to_model(mock_llm_connection, invocation_context)
309+
310+
assert invocation_context.session.state['k'] == 'v'
311+
# The partial content does not create a user content event.
312+
assert all(e.content is None for e in invocation_context.session.events)
313+
mock_llm_connection._send_content.assert_called_once_with(
314+
content, partial=True
315+
)
316+
317+
318+
@pytest.mark.asyncio
319+
async def test_send_to_model_state_delta_with_function_response(
320+
mock_llm_connection,
321+
):
322+
"""state_delta applies even when the content is a function response."""
323+
agent = Agent(name='test_agent', model='mock')
324+
invocation_context = await testing_utils.create_invocation_context(
325+
agent=agent, user_content=''
326+
)
327+
invocation_context.live_request_queue = LiveRequestQueue()
328+
329+
flow = TestBaseLlmFlow()
330+
331+
content = types.Content(
332+
role='user',
333+
parts=[
334+
types.Part.from_function_response(
335+
name='tool', response={'result': 'ok'}
336+
)
337+
],
338+
)
339+
invocation_context.live_request_queue.send(
340+
LiveRequest(content=content, state_delta={'k': 'v'})
341+
)
342+
invocation_context.live_request_queue.close()
343+
344+
await flow._send_to_model(mock_llm_connection, invocation_context)
345+
346+
assert invocation_context.session.state['k'] == 'v'
347+
# Function responses do not create a user content event.
348+
assert all(e.content is None for e in invocation_context.session.events)
349+
mock_llm_connection._send_content.assert_called_once_with(
350+
content, partial=False
351+
)
352+
353+
354+
@pytest.mark.asyncio
355+
async def test_send_to_model_state_delta_with_close(mock_llm_connection):
356+
"""state_delta is flushed even when the request also closes the connection."""
357+
agent = Agent(name='test_agent', model='mock')
358+
invocation_context = await testing_utils.create_invocation_context(
359+
agent=agent, user_content=''
360+
)
361+
invocation_context.live_request_queue = LiveRequestQueue()
362+
363+
flow = TestBaseLlmFlow()
364+
365+
invocation_context.live_request_queue.send(
366+
LiveRequest(state_delta={'k': 'v'}, close=True)
367+
)
368+
369+
await flow._send_to_model(mock_llm_connection, invocation_context)
370+
371+
assert invocation_context.session.state['k'] == 'v'
372+
mock_llm_connection.close.assert_called_once()

0 commit comments

Comments
 (0)