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(