-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(google): skip context replay on resumed realtime sessions #6000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b85fff7
7ab8e4f
9f75dfd
8603f0f
3eeeffc
659d49b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
devin-ai-integration[bot] marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,8 @@ | |
| from dataclasses import dataclass, field | ||
| from typing import Literal | ||
|
|
||
| from pydantic import Field | ||
|
|
||
| import google.auth.credentials | ||
| from google.auth._default_async import default_async | ||
| from google.genai import Client as GenAIClient, types | ||
|
|
@@ -44,6 +46,19 @@ | |
|
|
||
| lk_google_debug = int(os.getenv("LK_GOOGLE_DEBUG", 0)) | ||
|
|
||
|
|
||
| class _ChatCtxContent(types.LiveClientContent): | ||
| """Client content built from chat ctx items; the ids let the send task mark them sent.""" | ||
|
|
||
| item_ids: set[str] = Field(default_factory=set, exclude=True) | ||
|
|
||
|
|
||
| class _ChatCtxToolResponse(types.LiveClientToolResponse): | ||
| """Tool responses built from chat ctx items; the ids let the send task mark them sent.""" | ||
|
|
||
| item_ids: set[str] = Field(default_factory=set, exclude=True) | ||
|
|
||
|
|
||
| # stop rejecting tool calls after this many in a row to avoid a loop (tool_choice="none") | ||
| MAX_TOOL_CALL_REJECTIONS = 3 | ||
|
|
||
|
|
@@ -523,6 +538,12 @@ def __init__(self, realtime_model: RealtimeModel) -> None: | |
| if is_given(self._opts.session_resumption) | ||
| else None | ||
| ) | ||
| # chat ctx the handle stands for; None until the first handle arrives | ||
| self._resumption_chat_ctx: llm.ChatContext | None = None | ||
| # chat ctx received while no session is active, synced on the next connect | ||
| self._pending_chat_ctx: llm.ChatContext | None = None | ||
| # ids of chat ctx items queued but not yet sent, so a handle does not claim them | ||
| self._unsent_item_ids: set[str] = set() | ||
|
|
||
| self._in_user_activity = False | ||
| self._session_lock = asyncio.Lock() | ||
|
|
@@ -655,10 +676,18 @@ async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None: | |
| ) | ||
| async with self._session_lock: | ||
| if not self._active_session: | ||
| self._chat_ctx = chat_ctx | ||
| self._pending_chat_ctx = chat_ctx | ||
| return | ||
|
|
||
| diff_ops = llm.utils.compute_chat_ctx_diff(self._chat_ctx, chat_ctx) | ||
| self._sync_chat_ctx(chat_ctx) | ||
|
|
||
| def _sync_chat_ctx( | ||
| self, chat_ctx: llm.ChatContext, *, known: llm.ChatContext | None = None | ||
| ) -> None: | ||
| """Queue the items missing from `known` and adopt `chat_ctx` as the known state.""" | ||
| diff_ops = llm.utils.compute_chat_ctx_diff( | ||
| known if known is not None else self._chat_ctx, chat_ctx | ||
| ) | ||
|
|
||
| if diff_ops.to_remove: | ||
| logger.warning("Gemini Live does not support removing messages") | ||
|
|
@@ -694,17 +723,30 @@ async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None: | |
| tool_response_scheduling=self._opts.tool_response_scheduling, | ||
| supports_silent_scheduling=supports_silent_scheduling, | ||
| ) | ||
| turns: list[types.Content] = [] | ||
| if self._realtime_model.capabilities.mutable_chat_context: | ||
| turns_dict, _ = append_ctx.copy(exclude_function_call=True).to_provider_format( | ||
| format="google", inject_dummy_user_message=False | ||
| ) | ||
| turns = [types.Content.model_validate(turn) for turn in turns_dict] | ||
| if turns: | ||
| item_ids = { | ||
| item.id for item in append_ctx.items if item.type != "function_call_output" | ||
| } | ||
| self._unsent_item_ids |= item_ids | ||
| self._send_client_event( | ||
| types.LiveClientContent(turns=turns, turn_complete=False) | ||
| _ChatCtxContent(turns=turns, turn_complete=False, item_ids=item_ids) | ||
| ) | ||
| if tool_results: | ||
| self._send_client_event(tool_results) | ||
| item_ids = { | ||
| item.id for item in append_ctx.items if item.type == "function_call_output" | ||
| } | ||
| self._unsent_item_ids |= item_ids | ||
| self._send_client_event( | ||
| _ChatCtxToolResponse( | ||
| function_responses=tool_results.function_responses, item_ids=item_ids | ||
| ) | ||
| ) | ||
|
|
||
| # since we don't have a view of the history on the server side, we'll assume | ||
| # the current state is accurate. this isn't perfect because removals aren't done. | ||
|
|
@@ -720,7 +762,7 @@ async def update_tools(self, tools: list[llm.Tool]) -> None: | |
|
|
||
| @property | ||
| def chat_ctx(self) -> llm.ChatContext: | ||
| return self._chat_ctx.copy() | ||
| return (self._pending_chat_ctx or self._chat_ctx).copy() | ||
|
|
||
| @property | ||
| def tools(self) -> llm.ToolContext: | ||
|
|
@@ -920,34 +962,46 @@ async def _main_task(self) -> None: | |
| async with self._session_lock: | ||
| self._active_session = session | ||
|
|
||
| # Check for system/developer messages in initial chat context | ||
| system_msg_count = sum( | ||
| 1 | ||
| for msg in self._chat_ctx.messages() | ||
| if msg.role in ("system", "developer") | ||
| ) | ||
| if system_msg_count > 0: | ||
| logger.warning( | ||
| f"Gemini Realtime model '{self._opts.model}' does not support 'system' or " | ||
| f"'developer' roles in chat history. Dropping {system_msg_count} system " | ||
| f"message(s) from initial chat context during session initialization. " | ||
| f"Gemini Realtime only supports 'user' and 'model' roles. Use " | ||
| f"update_instructions() to set system-level context instead." | ||
| ) | ||
|
|
||
| turns_dict, _ = self._chat_ctx.copy( | ||
| exclude_function_call=True, | ||
| exclude_handoff=True, | ||
| exclude_instructions=True, | ||
| exclude_empty_message=True, | ||
| exclude_config_update=True, | ||
| ).to_provider_format(format="google", inject_dummy_user_message=False) | ||
| turns = [types.Content.model_validate(turn) for turn in turns_dict] | ||
| if turns: | ||
| await session.send_client_content( | ||
| turns=turns, # type: ignore | ||
| turn_complete=False, | ||
| pending_ctx, self._pending_chat_ctx = self._pending_chat_ctx, None | ||
| if self._session_resumption_handle is not None: | ||
| # the handle restores the conversation; send only what came after it | ||
| target = pending_ctx if pending_ctx is not None else self._chat_ctx | ||
| if self._resumption_chat_ctx is None: | ||
| self._chat_ctx = target | ||
| else: | ||
| self._sync_chat_ctx(target, known=self._resumption_chat_ctx) | ||
| else: | ||
| if pending_ctx is not None: | ||
| self._chat_ctx = pending_ctx | ||
|
|
||
| system_msg_count = sum( | ||
| 1 | ||
| for msg in self._chat_ctx.messages() | ||
| if msg.role in ("system", "developer") | ||
| ) | ||
| if system_msg_count > 0: | ||
| logger.warning( | ||
| f"Gemini Realtime model '{self._opts.model}' does not support 'system' or " | ||
| f"'developer' roles in chat history. Dropping {system_msg_count} system " | ||
| f"message(s) from initial chat context during session initialization. " | ||
| f"Gemini Realtime only supports 'user' and 'model' roles. Use " | ||
| f"update_instructions() to set system-level context instead." | ||
| ) | ||
|
|
||
| turns_dict, _ = self._chat_ctx.copy( | ||
| exclude_function_call=True, | ||
| exclude_handoff=True, | ||
| exclude_instructions=True, | ||
| exclude_empty_message=True, | ||
| exclude_config_update=True, | ||
| ).to_provider_format(format="google", inject_dummy_user_message=False) | ||
| turns = [types.Content.model_validate(turn) for turn in turns_dict] | ||
| if turns: | ||
| await session.send_client_content( | ||
| turns=turns, # type: ignore | ||
| turn_complete=False, | ||
| ) | ||
| self._unsent_item_ids.clear() | ||
|
|
||
| # queue up existing chat context | ||
| send_task = asyncio.create_task( | ||
|
|
@@ -1068,6 +1122,8 @@ async def _send_task(self, session: AsyncSession) -> None: | |
| else: | ||
| logger.warning(f"Warning: Received unhandled message type: {type(msg)}") | ||
|
|
||
| if isinstance(msg, _ChatCtxContent | _ChatCtxToolResponse): | ||
| self._unsent_item_ids -= msg.item_ids | ||
| if lk_google_debug and isinstance( | ||
| msg, | ||
| ( | ||
|
|
@@ -1154,6 +1210,13 @@ async def _recv_task(self, session: AsyncSession) -> None: | |
| self._session_resumption_handle = ( | ||
| response.session_resumption_update.new_handle | ||
| ) | ||
| self._resumption_chat_ctx = llm.ChatContext( | ||
| [ | ||
| item | ||
| for item in self._chat_ctx.items | ||
| if item.id not in self._unsent_item_ids | ||
| ] | ||
| ) | ||
|
Comment on lines
+1213
to
+1219
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Queued updates replay twice after failures When one context send fails with later events queued, Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. an error restart discards the queue as well, and the resume diff is the only thing that re-sends. |
||
|
|
||
| if response.server_content: | ||
| self._handle_server_content(response.server_content) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.