fix: Heal missing tool results before LiteLLM requests

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 <gweale@google.com>
PiperOrigin-RevId: 852340750
This commit is contained in:
George Weale
2026-01-05 09:49:20 -08:00
committed by Copybara-Service
parent 5b7c8c04d6
commit 6b7386b762
2 changed files with 103 additions and 0 deletions
+65
View File
@@ -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
+38
View File
@@ -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(