Skip to content

Commit 0b39e72

Browse files
wuliang229copybara-github
authored andcommitted
fix(live): stop live runs from writing back into the caller's RunConfig
Two places in the live path wrote into configuration the caller still owns, so a `RunConfig` came back out of a run holding fields the caller never set, and a `RunConfig` reused for a later run carried them into it. The basic request processor aliased two `RunConfig` sub-models straight into `LiveConnectConfig` rather than copying them, and live request assembly then mutates both while the session runs: `BaseLlmFlow.run_live` stamps every server-issued handle onto `session_resumption` when it reconnects and sets `transparent` there on a Vertex reconnect, and sets `initial_history_in_client_content` on `history_config` when it seeds a fresh connection with history. Deep copy both sub-models in `_build_basic_request`. This matches the treatment `_copy_request_scoped_fields` already gives `llm_request.config` in the same function, and for the same reason: request assembly must not write through into configuration the caller still owns. An absent sub-config stays `None` rather than becoming an empty object. `Runner.run_live` filled in its AUDIO default by assigning to the caller's `response_modalities`, so a config that expressed no preference came back out of the run pinned to AUDIO, and a config reused for a later text run would ask for audio. Write that default to a copy instead. The copy is shallow: deep copying a `RunConfig` raises `TypeError: cannot pickle` when `http_options` holds a live httpx client, and nothing there writes through into a sub-model. Co-authored-by: Liang Wu <wuliang@google.com> PiperOrigin-RevId: 966257920
1 parent 1d0b7a1 commit 0b39e72

5 files changed

Lines changed: 219 additions & 2 deletions

File tree

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
from __future__ import annotations
1818

1919
from typing import AsyncGenerator
20+
from typing import Optional
21+
from typing import TypeVar
2022

2123
from google.genai import types
24+
from pydantic import BaseModel
2225
from typing_extensions import override
2326

2427
from ...agents.invocation_context import InvocationContext
@@ -73,6 +76,14 @@ def _copy_http_options(
7376
)
7477

7578

79+
_ModelT = TypeVar('_ModelT', bound=BaseModel)
80+
81+
82+
def _copy_or_none(model: Optional[_ModelT]) -> Optional[_ModelT]:
83+
"""Returns a deep copy of a RunConfig sub-model that assembly then mutates."""
84+
return None if model is None else model.model_copy(deep=True)
85+
86+
7687
def _copy_request_scoped_fields(
7788
config: types.GenerateContentConfig,
7889
) -> types.GenerateContentConfig:
@@ -183,10 +194,20 @@ def _build_basic_request(
183194
llm_request.live_connect_config.proactivity = (
184195
None if is_gemini_3_x else run_config.proactivity
185196
)
186-
llm_request.live_connect_config.session_resumption = (
197+
# Copied rather than aliased: live request assembly writes into both of these
198+
# while the session runs. `BaseLlmFlow.run_live` stamps each server-issued
199+
# resumption handle onto `session_resumption`, and sets
200+
# `initial_history_in_client_content` on `history_config` when it seeds a
201+
# fresh connection with history. Aliasing the RunConfig's own objects makes
202+
# those writes outlive the invocation, so a RunConfig reused for a later run
203+
# would carry a stale handle into it. This mirrors what
204+
# `_copy_request_scoped_fields` already does for `llm_request.config`.
205+
llm_request.live_connect_config.session_resumption = _copy_or_none(
187206
run_config.session_resumption
188207
)
189-
llm_request.live_connect_config.history_config = run_config.history_config
208+
llm_request.live_connect_config.history_config = _copy_or_none(
209+
run_config.history_config
210+
)
190211
llm_request.live_connect_config.context_window_compression = (
191212
run_config.context_window_compression
192213
)

src/google/adk/runners.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,7 +1874,15 @@ async def run_live(
18741874
run_config = run_config or RunConfig()
18751875
# Some native audio models requires the modality to be set. So we set it to
18761876
# AUDIO by default.
1877+
#
1878+
# The default goes on a copy rather than on the caller's own RunConfig: a
1879+
# config that asked for nothing in particular would otherwise come back out
1880+
# of the run pinned to AUDIO, and a config reused for a later run would
1881+
# carry that choice into it. The copy is shallow on purpose. Deep copying a
1882+
# RunConfig raises `TypeError: cannot pickle` when `http_options` holds a
1883+
# live httpx client, and nothing here writes through into a sub-model.
18771884
if run_config.response_modalities is None:
1885+
run_config = run_config.model_copy()
18781886
run_config.response_modalities = [types.Modality.AUDIO]
18791887
if session is None and (user_id is None or session_id is None):
18801888
raise ValueError(

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,98 @@ async def mock_receive_2():
876876
assert invocation_context.live_session_resumption_handle == 'test_handle'
877877

878878

879+
@pytest.mark.asyncio
880+
async def test_reconnect_does_not_write_the_handle_into_the_run_config():
881+
"""A reconnect must not stamp the server's handle onto the caller's config.
882+
883+
The reconnect branch assigns the handle onto
884+
`llm_request.live_connect_config.session_resumption`. That object comes from
885+
the RunConfig, so aliasing it would leave the caller's own RunConfig holding
886+
a handle it never set, and reusing that RunConfig for a later run would
887+
silently resume this session.
888+
"""
889+
890+
real_model = Gemini()
891+
mock_connection = mock.AsyncMock()
892+
893+
async def mock_receive():
894+
yield LlmResponse(
895+
live_session_resumption_update=types.LiveServerSessionResumptionUpdate(
896+
new_handle='server_handle'
897+
)
898+
)
899+
raise ConnectionClosed(None, None)
900+
901+
mock_connection.receive = mock.Mock(side_effect=mock_receive)
902+
903+
agent = Agent(name='test_agent', model=real_model)
904+
# The caller enables resumption but holds no handle yet, which is how a
905+
# first run is configured.
906+
run_config_session_resumption = types.SessionResumptionConfig(
907+
transparent=True
908+
)
909+
invocation_context = await testing_utils.create_invocation_context(
910+
agent=agent,
911+
run_config=RunConfig(session_resumption=run_config_session_resumption),
912+
)
913+
invocation_context.live_request_queue = LiveRequestQueue()
914+
915+
flow = BaseLlmFlowForTesting()
916+
917+
# `BaseLlmFlow` has no request processors of its own, so the real request
918+
# builder has to run for the RunConfig to reach the live connect config at
919+
# all. Without it the flow just creates a fresh SessionResumptionConfig and
920+
# the aliasing under test never happens.
921+
async def mock_preprocess(ctx, req):
922+
from google.adk.flows.llm_flows.basic import _build_basic_request
923+
924+
_build_basic_request(ctx, req)
925+
if False: # pylint: disable=using-constant-test
926+
yield
927+
928+
with (
929+
mock.patch.object(flow, '_preprocess_async', side_effect=mock_preprocess),
930+
mock.patch.object(flow, '_send_to_model', new_callable=AsyncMock),
931+
):
932+
mock_connection_2 = mock.AsyncMock()
933+
934+
class NonRetryableError(Exception):
935+
pass
936+
937+
async def mock_receive_2():
938+
yield LlmResponse(
939+
content=types.Content(parts=[types.Part.from_text(text='hi')])
940+
)
941+
raise NonRetryableError('stop')
942+
943+
mock_connection_2.receive = mock.Mock(side_effect=mock_receive_2)
944+
945+
mock_aenter = mock.AsyncMock()
946+
mock_aenter.side_effect = [mock_connection, mock_connection_2]
947+
948+
with mock.patch(
949+
'google.adk.models.google_llm.Gemini.connect'
950+
) as mock_connect:
951+
mock_connect.return_value.__aenter__ = mock_aenter
952+
953+
try:
954+
async for _ in flow.run_live(invocation_context):
955+
pass
956+
except NonRetryableError:
957+
pass
958+
959+
# The reconnect happened and carried the handle...
960+
assert mock_connect.call_count == 2
961+
second_request = mock_connect.call_args_list[1][0][0]
962+
assert (
963+
second_request.live_connect_config.session_resumption.handle
964+
== 'server_handle'
965+
)
966+
# ...but the caller's own config is untouched.
967+
assert run_config_session_resumption.handle is None
968+
assert invocation_context.run_config.session_resumption.handle is None
969+
970+
879971
@pytest.mark.asyncio
880972
async def test_run_live_skips_send_history_on_resumption():
881973
"""Test that run_live skips send_history when resuming a session."""

tests/unittests/flows/llm_flows/test_basic_processor.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,3 +584,69 @@ async def test_safety_settings_do_not_accumulate_across_invocations(self):
584584
pass
585585

586586
assert len(second_request.config.safety_settings) == 1
587+
588+
@pytest.mark.asyncio
589+
async def test_run_config_session_resumption_object_is_not_aliased(self):
590+
"""The request must not hold the RunConfig's own SessionResumptionConfig.
591+
592+
`BaseLlmFlow.run_live` stamps every server-issued handle onto the request's
593+
`session_resumption`, so aliasing would write those handles back into the
594+
caller's RunConfig.
595+
"""
596+
agent = LlmAgent(name='test_agent', model='gemini-1.5-flash')
597+
invocation_context = await _create_invocation_context(agent)
598+
run_config_session_resumption = types.SessionResumptionConfig(
599+
handle='caller_handle'
600+
)
601+
invocation_context.run_config.session_resumption = (
602+
run_config_session_resumption
603+
)
604+
llm_request = LlmRequest()
605+
606+
processor = _BasicLlmRequestProcessor()
607+
async for _ in processor.run_async(invocation_context, llm_request):
608+
pass
609+
610+
assert (
611+
llm_request.live_connect_config.session_resumption.handle
612+
== 'caller_handle'
613+
)
614+
llm_request.live_connect_config.session_resumption.handle = 'server_handle'
615+
assert run_config_session_resumption.handle == 'caller_handle'
616+
617+
@pytest.mark.asyncio
618+
async def test_run_config_history_config_object_is_not_aliased(self):
619+
"""The request must not hold the RunConfig's own HistoryConfig.
620+
621+
`BaseLlmFlow.run_live` sets `initial_history_in_client_content` on the
622+
request when it seeds a fresh connection with history, so aliasing would
623+
write that back into the caller's RunConfig.
624+
"""
625+
agent = LlmAgent(name='test_agent', model='gemini-1.5-flash')
626+
invocation_context = await _create_invocation_context(agent)
627+
run_config_history_config = types.HistoryConfig()
628+
invocation_context.run_config.history_config = run_config_history_config
629+
llm_request = LlmRequest()
630+
631+
processor = _BasicLlmRequestProcessor()
632+
async for _ in processor.run_async(invocation_context, llm_request):
633+
pass
634+
635+
llm_request.live_connect_config.history_config.initial_history_in_client_content = (
636+
True
637+
)
638+
assert run_config_history_config.initial_history_in_client_content is None
639+
640+
@pytest.mark.asyncio
641+
async def test_absent_live_sub_configs_stay_none(self):
642+
"""Copying must not turn an unset RunConfig sub-config into an object."""
643+
agent = LlmAgent(name='test_agent', model='gemini-1.5-flash')
644+
invocation_context = await _create_invocation_context(agent)
645+
llm_request = LlmRequest()
646+
647+
processor = _BasicLlmRequestProcessor()
648+
async for _ in processor.run_async(invocation_context, llm_request):
649+
pass
650+
651+
assert llm_request.live_connect_config.session_resumption is None
652+
assert llm_request.live_connect_config.history_config is None

tests/unittests/streaming/test_live_streaming_configs.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,3 +835,33 @@ def test_streaming_with_explicit_vad_signal():
835835
assert (
836836
llm_request_sent_to_mock.live_connect_config.explicit_vad_signal is True
837837
)
838+
839+
840+
def test_run_live_does_not_write_the_audio_default_into_the_run_config():
841+
"""The AUDIO default must not be stamped onto the caller's RunConfig.
842+
843+
`run_live` fills in AUDIO for a caller that expressed no preference. Writing
844+
that back into the caller's own RunConfig would hand them a config pinned to
845+
AUDIO, so a config reused for a later text run would silently ask for audio.
846+
"""
847+
848+
mock_model = testing_utils.MockModel.create([LlmResponse(turn_complete=True)])
849+
850+
root_agent = Agent(name='root_agent', model=mock_model, tools=[])
851+
runner = testing_utils.InMemoryRunner(root_agent=root_agent)
852+
853+
run_config = RunConfig()
854+
855+
live_request_queue = LiveRequestQueue()
856+
live_request_queue.send_realtime(
857+
blob=types.Blob(data=b'\x00\xFF', mime_type='audio/pcm')
858+
)
859+
runner.run_live(live_request_queue, run_config)
860+
861+
assert run_config.response_modalities is None
862+
assert 'response_modalities' not in run_config.model_fields_set
863+
# The run itself still gets the default.
864+
assert len(mock_model.requests) == 1
865+
assert mock_model.requests[0].live_connect_config.response_modalities == [
866+
types.Modality.AUDIO
867+
]

0 commit comments

Comments
 (0)