Repository navigation
fix(google): finalize the realtime generation once per message so a combined turn_complete + tool_call isn't lost #8376
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
base: main
Are you sure you want to change the base?
Changes from all commits
884212f
776dbb6
b1f2501
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1283,6 +1283,82 @@ async def _send_task(self, session: AsyncSession) -> None: | |
| finally: | ||
| logger.debug("send task finished.") | ||
|
|
||
| def _handle_live_server_message(self, response: types.LiveServerMessage) -> None: | ||
| if lk_google_debug: | ||
| resp_copy = response.model_dump(exclude_defaults=True) | ||
| # remove audio from debugging logs | ||
| if ( | ||
| (sc := resp_copy.get("server_content")) | ||
| and (mt := sc.get("model_turn")) | ||
| and (parts := mt.get("parts")) | ||
| ): | ||
| for part in parts: | ||
| if part and part.get("inline_data"): | ||
| part["inline_data"] = "<audio>" | ||
| logger.debug("<<< received response", extra={"lk.pii.response": resp_copy}) | ||
|
|
||
| if response.tool_call and self._opts.tool_choice == "none": | ||
| # reject without opening a generation, so the pending generate_reply | ||
| # stays bound to the model's eventual reply and tools stay suppressed | ||
| # for the whole turn. | ||
| self._reject_tool_calls(response.tool_call.function_calls or []) | ||
| return | ||
|
|
||
| if not self._current_generation or self._current_generation._done: | ||
| if (sc := response.server_content) and sc.interrupted: | ||
| # two cases an interrupted event is sent without an active generation | ||
| # 1) the generation is done but playout is not finished (turn_complete -> interrupted) | ||
| # 2) the generation is not started (interrupted -> turn_complete) | ||
| # for both cases, we interrupt the agent if there is no pending generation from `generate_reply` | ||
| # for the second case, the pending generation will be stopped by `turn_complete` event coming later | ||
| if not self._pending_generation_fut: | ||
| self._handle_input_speech_started() | ||
|
|
||
| sc.interrupted = None | ||
| sc_copy = sc.model_dump(exclude_none=True) | ||
| if not sc_copy: | ||
| # ignore empty server content | ||
| response.server_content = None | ||
| if lk_google_debug: | ||
| logger.debug("ignoring empty server content") | ||
|
|
||
| if self._is_new_generation(response): | ||
| self._start_new_generation() | ||
| if lk_google_debug: | ||
| logger.debug(f"new generation started: {self._current_generation}") | ||
|
|
||
| if response.session_resumption_update: | ||
| if ( | ||
| response.session_resumption_update.resumable | ||
| and response.session_resumption_update.new_handle | ||
| ): | ||
| 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] | ||
| ) | ||
|
|
||
| calls: list[llm.FunctionCall] = [] | ||
| if response.server_content: | ||
| self._handle_server_content(response.server_content) | ||
| if response.tool_call: | ||
| calls = self._handle_tool_calls(response.tool_call) | ||
|
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. nit: return the list of
Author
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. Done in b1f2501. One thing came up while doing it. So the loop inserts each call with the insertion time: for call in calls:
self._chat_ctx.insert(call.model_copy(update={"created_at": time.time()}))The call sent on Tests:
|
||
| if ((sc := response.server_content) and sc.turn_complete) or response.tool_call: | ||
| self._mark_current_generation_done() | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| # mirror the server history, after the turn's messages added above, so the | ||
| # tool results the framework appends to `chat_ctx` keep their calls | ||
| for call in calls: | ||
| self._chat_ctx.insert(call.model_copy(update={"created_at": time.time()})) | ||
| if response.tool_call_cancellation: | ||
| self._handle_tool_call_cancellation(response.tool_call_cancellation) | ||
| if response.usage_metadata: | ||
| self._handle_usage_metadata(response.usage_metadata) | ||
| if response.go_away: | ||
| self._handle_go_away(response.go_away) | ||
|
|
||
| if self._num_retries > 0: | ||
| self._num_retries = 0 # reset the retry counter | ||
|
|
||
| async def _recv_task(self, session: AsyncSession) -> None: | ||
| try: | ||
| while True: | ||
|
|
@@ -1294,78 +1370,7 @@ async def _recv_task(self, session: AsyncSession) -> None: | |
| break | ||
|
|
||
| async for response in session.receive(): | ||
| if lk_google_debug: | ||
| resp_copy = response.model_dump(exclude_defaults=True) | ||
| # remove audio from debugging logs | ||
| if ( | ||
| (sc := resp_copy.get("server_content")) | ||
| and (mt := sc.get("model_turn")) | ||
| and (parts := mt.get("parts")) | ||
| ): | ||
| for part in parts: | ||
| if part and part.get("inline_data"): | ||
| part["inline_data"] = "<audio>" | ||
| logger.debug("<<< received response", extra={"lk.pii.response": resp_copy}) | ||
|
|
||
| if response.tool_call and self._opts.tool_choice == "none": | ||
| # reject without opening a generation, so the pending generate_reply | ||
| # stays bound to the model's eventual reply and tools stay suppressed | ||
| # for the whole turn. | ||
| self._reject_tool_calls(response.tool_call.function_calls or []) | ||
| continue | ||
|
|
||
| if not self._current_generation or self._current_generation._done: | ||
| if (sc := response.server_content) and sc.interrupted: | ||
| # two cases an interrupted event is sent without an active generation | ||
| # 1) the generation is done but playout is not finished (turn_complete -> interrupted) | ||
| # 2) the generation is not started (interrupted -> turn_complete) | ||
| # for both cases, we interrupt the agent if there is no pending generation from `generate_reply` | ||
| # for the second case, the pending generation will be stopped by `turn_complete` event coming later | ||
| if not self._pending_generation_fut: | ||
| self._handle_input_speech_started() | ||
|
|
||
| sc.interrupted = None | ||
| sc_copy = sc.model_dump(exclude_none=True) | ||
| if not sc_copy: | ||
| # ignore empty server content | ||
| response.server_content = None | ||
| if lk_google_debug: | ||
| logger.debug("ignoring empty server content") | ||
|
|
||
| if self._is_new_generation(response): | ||
| self._start_new_generation() | ||
| if lk_google_debug: | ||
| logger.debug(f"new generation started: {self._current_generation}") | ||
|
|
||
| if response.session_resumption_update: | ||
| if ( | ||
| response.session_resumption_update.resumable | ||
| and response.session_resumption_update.new_handle | ||
| ): | ||
| 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 | ||
| ] | ||
| ) | ||
|
|
||
| if response.server_content: | ||
| self._handle_server_content(response.server_content) | ||
| if response.tool_call: | ||
| self._handle_tool_calls(response.tool_call) | ||
| if response.tool_call_cancellation: | ||
| self._handle_tool_call_cancellation(response.tool_call_cancellation) | ||
| if response.usage_metadata: | ||
| self._handle_usage_metadata(response.usage_metadata) | ||
| if response.go_away: | ||
| self._handle_go_away(response.go_away) | ||
|
|
||
| if self._num_retries > 0: | ||
| self._num_retries = 0 # reset the retry counter | ||
| self._handle_live_server_message(response) | ||
|
|
||
| # TODO(dz): a server-side turn is complete | ||
| except Exception as e: | ||
|
|
@@ -1577,9 +1582,6 @@ def _handle_server_content(self, server_content: types.LiveServerContent) -> Non | |
| # interrupt agent if there is no pending user initiated generation | ||
| self._handle_input_speech_started() | ||
|
|
||
| if server_content.turn_complete: | ||
| self._mark_current_generation_done() | ||
|
|
||
| def _mark_current_generation_done(self) -> None: | ||
| if not self._current_generation or self._current_generation._done: | ||
| return | ||
|
|
@@ -1678,21 +1680,23 @@ def _reject_tool_calls(self, function_calls: list[types.FunctionCall]) -> None: | |
| ] | ||
| self._send_client_event(types.LiveClientToolResponse(function_responses=responses)) | ||
|
|
||
| def _handle_tool_calls(self, tool_call: types.LiveServerToolCall) -> None: | ||
| def _handle_tool_calls(self, tool_call: types.LiveServerToolCall) -> list[llm.FunctionCall]: | ||
| if not self._current_generation: | ||
| logger.warning("received tool call but no active generation.") | ||
| return | ||
| return [] | ||
|
|
||
| gen = self._current_generation | ||
| calls: list[tuple[str, str, str]] = [] | ||
| calls: list[llm.FunctionCall] = [] | ||
| for fnc_call in tool_call.function_calls or []: | ||
| arguments = json.dumps(fnc_call.args) | ||
| call_id = fnc_call.id | ||
| if not call_id: | ||
| call_id = utils.shortuuid("fnc-call-") | ||
| self._synthetic_call_ids.add(call_id) | ||
| self._open_call_ids.add(call_id) | ||
| calls.append((call_id, fnc_call.name or "", arguments)) | ||
| calls.append( | ||
| llm.FunctionCall(call_id=call_id, name=fnc_call.name or "", arguments=arguments) | ||
| ) | ||
|
|
||
| gen.function_ch.send_nowait( | ||
| llm.FunctionCall( | ||
|
|
@@ -1701,12 +1705,8 @@ def _handle_tool_calls(self, tool_call: types.LiveServerToolCall) -> None: | |
| arguments=arguments, | ||
| ) | ||
| ) | ||
| self._mark_current_generation_done() | ||
|
|
||
| # mirror the server history, after the turn's messages added above, so the | ||
| # tool results the framework appends to `chat_ctx` keep their calls | ||
| for call_id, name, arguments in calls: | ||
| self._chat_ctx.insert(llm.FunctionCall(call_id=call_id, name=name, arguments=arguments)) | ||
| return calls | ||
|
|
||
| def _handle_tool_call_cancellation( | ||
| self, tool_call_cancellation: types.LiveServerToolCallCancellation | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 New message handler lacks required documentation
_handle_live_server_messagehas no docstring, despite the repository's requirement to document every new method.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm leaving this one as it is. AGENTS.md asks for Google-style docstrings where a docstring is written, and the private per-message handlers in this file don't have one:
_handle_server_content,_handle_tool_calls,_handle_usage_metadataand_handle_go_away._handle_live_server_messageis the same kind of method, so it follows that convention. Its behaviour is pinned bytest_combined_turn_complete_and_tool_call_finalizes_once,test_lone_tool_call_finalizes_generationandtest_tool_call_is_recorded_after_the_turn_it_ends.