Skip to content

Commit 02db0ff

Browse files
AtulJoshi1206copybara-github
authored andcommitted
fix: keep the live resumption handle through non-resumable updates
Merge #7939 The Live API sends session_resumption_update messages with no handle while the session cannot be resumed (the model is generating or a tool call is running). The live flow stored new_handle from every update, so those wiped the last good handle and a GoAway or dropped connection in that window reconnected without resumption or ended the run. The stored handle is now replaced only when an update carries one; the update events are still yielded as before. PiperOrigin-RevId: 1014061479
1 parent 73cfd62 commit 02db0ff

2 files changed

Lines changed: 182 additions & 3 deletions

File tree

‎src/google/adk/live/_live_llm_flow.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,12 @@ def get_author_for_event(llm_response: LlmResponse) -> str:
250250
'Update session resumption handle:'
251251
f' {llm_response.live_session_resumption_update}.'
252252
)
253-
invocation_context.live_session_resumption_handle = (
254-
llm_response.live_session_resumption_update.new_handle
255-
)
253+
# Updates sent while the session can't be resumed (the model is
254+
# generating or a tool call is running) carry no handle. Keep the
255+
# last handle the server offered, so a reconnect can still resume.
256+
new_handle = llm_response.live_session_resumption_update.new_handle
257+
if new_handle:
258+
invocation_context.live_session_resumption_handle = new_handle
256259
if llm_response.go_away:
257260
logger.info(f'Received go away signal: {llm_response.go_away}')
258261
# The server signals that it will close the connection soon.

‎tests/unittests/live/test_live_llm_flow.py‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
import contextlib
2021
import logging
22+
from typing import AsyncGenerator
2123
from unittest import mock
2224

2325
from google.adk.agents.invocation_context import InvocationContext
@@ -33,12 +35,15 @@
3335
from google.adk.live.live_request_queue import LiveRequestQueue
3436
from google.adk.models.llm_request import LlmRequest
3537
from google.adk.models.llm_response import LlmResponse
38+
from google.adk.runners import Runner
3639
from google.adk.sessions.in_memory_session_service import InMemorySessionService
3740
from google.adk.sessions.session import Session
3841
from google.adk.tools.set_model_response_tool import SetModelResponseTool
42+
from google.genai import errors
3943
from google.genai import types
4044
from pydantic import BaseModel
4145
import pytest
46+
from websockets.exceptions import ConnectionClosed
4247

4348
from .. import testing_utils
4449

@@ -547,6 +552,177 @@ async def _receive():
547552
]
548553

549554

555+
@pytest.mark.parametrize(
556+
'update_without_handle',
557+
[
558+
types.LiveServerSessionResumptionUpdate(resumable=False),
559+
types.LiveServerSessionResumptionUpdate(
560+
last_consumed_client_message_index=7
561+
),
562+
],
563+
ids=['not_resumable', 'consumed_index_only'],
564+
)
565+
async def test_receive_from_model_keeps_the_last_handle_the_server_offered(
566+
update_without_handle: types.LiveServerSessionResumptionUpdate,
567+
) -> None:
568+
"""An update that carries no handle does not erase the one held.
569+
570+
While the model is generating or a tool call is in flight, the server reports
571+
the session as not resumable with an update that has no handle. The handle a
572+
reconnect resumes with must stay the latest one the server actually offered.
573+
"""
574+
flow = _TestBaseLlmFlow()
575+
context = _create_test_context()
576+
calls = 0
577+
578+
async def _receive() -> AsyncGenerator[LlmResponse, None]:
579+
nonlocal calls
580+
calls += 1
581+
if calls == 1:
582+
for update in (
583+
types.LiveServerSessionResumptionUpdate(
584+
new_handle='handle-1', resumable=True
585+
),
586+
update_without_handle,
587+
types.LiveServerSessionResumptionUpdate(
588+
new_handle='handle-2', resumable=True
589+
),
590+
update_without_handle,
591+
):
592+
yield LlmResponse(live_session_resumption_update=update)
593+
594+
connection = mock.MagicMock(receive=_receive)
595+
596+
async for _ in _live_llm_flow.receive_from_model(
597+
flow, connection, context, LlmRequest()
598+
):
599+
pass
600+
601+
assert context.live_session_resumption_handle == 'handle-2'
602+
603+
604+
@pytest.mark.parametrize(
605+
'interruption',
606+
[
607+
LlmResponse(go_away=types.LiveServerGoAway(time_left='10s')),
608+
errors.APIError(1006, {}),
609+
errors.APIError(1011, {}),
610+
ConnectionClosed(None, None),
611+
],
612+
ids=['go_away', 'abnormal_closure', 'internal_error', 'connection_closed'],
613+
)
614+
async def test_run_live_resumes_a_connection_lost_while_not_resumable(
615+
interruption: LlmResponse | Exception,
616+
) -> None:
617+
"""A connection lost while the model is answering still resumes the session.
618+
619+
Setup: the server offers a handle, then reports the session as not resumable
620+
because the model has started answering, and then the connection goes: a
621+
GoAway, or a connection that drops.
622+
Act: run the live session through `Runner.run_live`.
623+
Assert: the run reconnects with the handle the server offered, and does not
624+
replay the conversation history that the resumed session already holds.
625+
"""
626+
627+
class _ScriptedConnection(testing_utils.MockLlmConnection):
628+
"""Plays one connection's script once, raising the exceptions in it."""
629+
630+
def __init__(self, script: list[LlmResponse | Exception]):
631+
super().__init__(llm_responses=[])
632+
self.script = script
633+
self.history: list[types.Content] | None = None
634+
635+
async def send_history(self, history: list[types.Content]) -> None:
636+
self.history = history
637+
638+
async def receive(self) -> AsyncGenerator[LlmResponse, None]:
639+
while self.script:
640+
item = self.script.pop(0)
641+
if isinstance(item, Exception):
642+
raise item
643+
yield item
644+
645+
class _ScriptedModel(testing_utils.MockModel):
646+
"""Opens the next scripted connection on each connect."""
647+
648+
scripts: list[list[LlmResponse | Exception]]
649+
connections: list[_ScriptedConnection] = []
650+
handles: list[str | None] = []
651+
652+
# MockModel.connect is annotated as returning the connection itself, so
653+
# mypy reads its yield type as Never and rejects any typed override.
654+
@contextlib.asynccontextmanager
655+
async def connect( # type: ignore[override]
656+
self, llm_request: LlmRequest
657+
) -> AsyncGenerator[_ScriptedConnection, None]:
658+
resumption = llm_request.live_connect_config.session_resumption
659+
self.handles.append(resumption.handle if resumption else None)
660+
connection = _ScriptedConnection(self.scripts[len(self.connections)])
661+
self.connections.append(connection)
662+
yield connection
663+
664+
def _resumption_update(
665+
*, new_handle: str | None = None, resumable: bool
666+
) -> LlmResponse:
667+
return LlmResponse(
668+
live_session_resumption_update=types.LiveServerSessionResumptionUpdate(
669+
new_handle=new_handle, resumable=resumable
670+
)
671+
)
672+
673+
model = _ScriptedModel(
674+
responses=[],
675+
scripts=[
676+
[
677+
_resumption_update(new_handle='handle-1', resumable=True),
678+
_resumption_update(resumable=False),
679+
interruption,
680+
],
681+
# The resumed connection has nothing more to send, which ends the
682+
# run.
683+
[],
684+
],
685+
)
686+
session_service = InMemorySessionService()
687+
session = await session_service.create_session(app_name='app', user_id='u')
688+
# An earlier turn, which a session started from scratch replays.
689+
await session_service.append_event(
690+
session,
691+
Event(
692+
invocation_id='earlier',
693+
author='user',
694+
content=types.UserContent('Book a table for two.'),
695+
),
696+
)
697+
runner = Runner(
698+
app_name='app',
699+
agent=LlmAgent(name='root_agent', model=model),
700+
session_service=session_service,
701+
)
702+
703+
async def _run_to_end() -> None:
704+
async with contextlib.aclosing(
705+
runner.run_live(
706+
user_id='u',
707+
session_id=session.id,
708+
live_request_queue=LiveRequestQueue(),
709+
run_config=RunConfig(
710+
session_resumption=types.SessionResumptionConfig()
711+
),
712+
)
713+
) as events:
714+
async for _ in events:
715+
pass
716+
717+
await asyncio.wait_for(_run_to_end(), timeout=5)
718+
719+
assert model.handles == [None, 'handle-1']
720+
# The first connection started the session and replayed the earlier turn;
721+
# the resumed one must not replay it into the session that already has it.
722+
assert model.connections[0].history
723+
assert model.connections[1].history is None
724+
725+
550726
async def test_turn_complete_saves_image_frames_sent_to_the_model():
551727
"""Camera frames sent during a turn are saved as one archive at its end."""
552728
flow = _TestBaseLlmFlow()

0 commit comments

Comments
 (0)