From 6b7386b7620bbc51cda8c1c6d9914549536640e6 Mon Sep 17 00:00:00 2001 From: George Weale Date: Mon, 5 Jan 2026 09:48:53 -0800 Subject: [PATCH] fix: Heal missing tool results before LiteLLM requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect assistant tool calls that lack matching tool results in the history and insert placeholder tool messages so strict providers don’t reject the request. Prevents crash loops when executions are interrupted mid-tool call. Close #3971 Co-authored-by: George Weale PiperOrigin-RevId: 852340750 --- src/google/adk/models/lite_llm.py | 65 ++++++++++++++++++++++++++ tests/unittests/models/test_litellm.py | 38 +++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 14047398..975acc31 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -104,6 +104,11 @@ _SUPPORTED_FILE_CONTENT_MIME_TYPES = frozenset({ # Providers that require file_id instead of inline file_data _FILE_ID_REQUIRED_PROVIDERS = frozenset({"openai", "azure"}) +_MISSING_TOOL_RESULT_MESSAGE = ( + "Error: Missing tool result (tool execution may have been interrupted " + "before a response was recorded)." +) + def _get_provider_from_model(model: str) -> str: """Extracts the provider name from a LiteLLM model string. @@ -516,6 +521,65 @@ async def _content_to_message_param( ) +def _ensure_tool_results(messages: List[Message]) -> List[Message]: + """Insert placeholder tool messages for missing tool results. + + LiteLLM-backed providers like OpenAI and Anthropic reject histories where an + assistant tool call is not followed by tool responses before the next + non-tool message. This helps recover from interrupted tool execution. + """ + if not messages: + return messages + + healed_messages: List[Message] = [] + pending_tool_call_ids: List[str] = [] + + for message in messages: + role = message.get("role") + if pending_tool_call_ids and role != "tool": + logger.warning( + "Missing tool results for tool_call_id(s): %s", + pending_tool_call_ids, + ) + healed_messages.extend( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=_MISSING_TOOL_RESULT_MESSAGE, + ) + for tool_call_id in pending_tool_call_ids + ) + pending_tool_call_ids = [] + + if role == "assistant": + tool_calls = message.get("tool_calls") or [] + pending_tool_call_ids = [ + tool_call.get("id") for tool_call in tool_calls if tool_call.get("id") + ] + elif role == "tool": + tool_call_id = message.get("tool_call_id") + if tool_call_id in pending_tool_call_ids: + pending_tool_call_ids.remove(tool_call_id) + + healed_messages.append(message) + + if pending_tool_call_ids: + logger.warning( + "Missing tool results for tool_call_id(s): %s", + pending_tool_call_ids, + ) + healed_messages.extend( + ChatCompletionToolMessage( + role="tool", + tool_call_id=tool_call_id, + content=_MISSING_TOOL_RESULT_MESSAGE, + ) + for tool_call_id in pending_tool_call_ids + ) + + return healed_messages + + async def _get_content( parts: Iterable[types.Part], *, @@ -1266,6 +1330,7 @@ async def _get_completion_inputs( content=llm_request.config.system_instruction, ), ) + messages = _ensure_tool_results(messages) # 2. Convert tool declarations tools: Optional[List[Dict]] = None diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 4cf0329a..ca36966c 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -32,6 +32,7 @@ from google.adk.models.lite_llm import _get_completion_inputs from google.adk.models.lite_llm import _get_content from google.adk.models.lite_llm import _get_provider_from_model from google.adk.models.lite_llm import _message_to_generate_content_response +from google.adk.models.lite_llm import _MISSING_TOOL_RESULT_MESSAGE from google.adk.models.lite_llm import _model_response_to_chunk from google.adk.models.lite_llm import _model_response_to_generate_content_response from google.adk.models.lite_llm import _parse_tool_calls_from_text @@ -470,6 +471,43 @@ async def test_get_completion_inputs_uses_passed_model_for_gemini_format(): assert "response_schema" in response_format +@pytest.mark.asyncio +async def test_get_completion_inputs_inserts_missing_tool_results(): + user_content = types.Content( + role="user", parts=[types.Part.from_text(text="Hi")] + ) + assistant_content = types.Content( + role="assistant", + parts=[ + types.Part.from_text(text="Calling tool."), + types.Part.from_function_call( + name="get_weather", args={"location": "Seoul"} + ), + ], + ) + assistant_content.parts[1].function_call.id = "tool_call_1" + followup_user = types.Content( + role="user", parts=[types.Part.from_text(text="Next question.")] + ) + + llm_request = LlmRequest( + contents=[user_content, assistant_content, followup_user] + ) + messages, _, _, _ = await _get_completion_inputs( + llm_request, model="openai/gpt-4o" + ) + + assert [message["role"] for message in messages] == [ + "user", + "assistant", + "tool", + "user", + ] + tool_message = messages[2] + assert tool_message["tool_call_id"] == "tool_call_1" + assert tool_message["content"] == _MISSING_TOOL_RESULT_MESSAGE + + def test_schema_to_dict_filters_none_enum_values(): # Use model_construct to bypass strict enum validation. top_level_schema = types.Schema.model_construct(