Skip to content

422 Error: Tool Call ID required on tool calls #6454

Description

@chrismathers-8451

Describe the Bug:
When a Gemini "thinking" model's thought_signature is only available embedded in the tool call id (the third fallback checked by _extract_thought_signature_from_tool_call in google/adk/models/lite_llm.py, used when the response doesn't expose the signature via extra_content.google.thought_signature or provider_specific_fields), _message_to_generate_content_response correctly extracts the signature for part.thought_signature, but never strips that same __thought__<signature> suffix from the id before assigning it to part.function_call.id:

# google/adk/models/lite_llm.py, _message_to_generate_content_response
thought_signature = _extract_thought_signature_from_tool_call(tool_call)
part = types.Part.from_function_call(
    name=tool_call.function.name,
    args=_parse_tool_call_arguments(tool_call.function.arguments),
)
part.function_call.id = tool_call.id          # <-- bug: never split on _THOUGHT_SIGNATURE_SEPARATOR
if thought_signature:
    part.thought_signature = thought_signature

The resulting function_call.id is a several-hundred-character string containing raw (non-URL-safe) base64 characters (/, +). This id gets persisted verbatim into session history (confirmed via direct inspection of a DatabaseSessionService-backed Postgres events row — see Logs). On any later turn where the same agent that made that call is replayed (its own history isn't converted to descriptive text the way other agents' events are, since it's the current agent's own authored event), that malformed id is sent to the model as a literal tool_call_id and rejected by the downstream OpenAI-compatible endpoint/proxy — and because the corrupted id is now permanently part of the session's event history, every subsequent turn on that thread fails identically, permanently breaking the conversation.

This was originally introduced (not regressed) by the PR that added thought_signature preservation itself — see Regression section.

Steps to Reproduce:

  1. Install google-adk==2.5.0 (also confirmed present on main as of this writing) with the LiteLLM extra.
  2. Construct a litellm tool-call response where the thought_signature is only present embedded in the tool call id via the __thought__ separator (i.e. no extra_content/provider_specific_fields set — this is the shape some upstream proxy paths return for Gemini 2.5 "thinking" models).
  3. Pass that message through _message_to_generate_content_response.
  4. Inspect response.content.parts[0].function_call.id — see Minimal Reproduction Code below for a runnable snippet.
  5. Observe the id is not stripped of the __thought__<signature> suffix.

Expected Behavior:
part.function_call.id should be the clean, original tool-call id (e.g. call_789), with part.thought_signature populated separately — matching the behavior when the signature arrives via extra_content or provider_specific_fields (paths 1–2), where the id is never touched.

Observed Behavior:
part.function_call.id retains the entire raw string, e.g. call_789__thought__<base64 signature>. When this id is later replayed to the model as a tool_call_id, the request is rejected. In our deployment (LiteLLM proxy in front of Gemini 2.5 Flash), this surfaces as:

litellm.exceptions.BadRequestError: litellm.BadRequestError: Litellm_proxyException - Error code: 422 - {'detail': {'error': '{"error":"Messages[6].ToolCallID: Tool Call ID required on tool calls"}'}}

Environment Details:

  • ADK Library Version (pip show google-adk): 2.4.0 (installed/reproduced here); confirmed the same code is present unchanged in the 2.5.0 tag and on main
  • Desktop OS: macOS (local repro); production occurrence observed on Linux (AKS) — the bug is in pure data-handling logic, not OS-dependent
  • Python Version (python -V): 3.12.7

Model Information:

  • Are you using LiteLLM: Yes
  • Which model is being used: gemini-2.5-flash, with thinking enabled, via a LiteLLM proxy in front of an internal OpenAI-compatible Gemini endpoint

🟡 Optional Information

Providing this information greatly speeds up the resolution process.

Regression:
Not a regression from a previously-working version — this is an oversight in the PR that introduced thought_signature preservation for LiteLLM tool calls itself: ae565be "fix: Preserve thought_signature in LiteLLM tool calls" (closes #4650). That commit added _extract_thought_signature_from_tool_call() and correctly used it to populate part.thought_signature, but never reused the same split to clean up tool_call.id on the same code path. The commit's own added test, test_extract_thought_signature_from_id, only asserts the extractor returns the correct signature — it never asserts part.function_call.id is clean afterward, which is presumably why this slipped through review.

Logs:

Traceback (most recent call last):
  File ".../litellm/main.py", line 637, in acompletion
    response = await init_response
  ...
  File ".../litellm/litellm_core_utils/exception_mapping_utils.py", line 422, in _map_openai_exception
    raise BadRequestError(
litellm.exceptions.BadRequestError: litellm.BadRequestError: Litellm_proxyException - Error code: 422 - {'detail': {'error': '{"error":"Messages[6].ToolCallID: Tool Call ID required on tool calls"}'}}

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../google/adk/runners.py", line 558, in _drive_root_node
    await root_ctx._run_node_internal(...)
  File ".../google/adk/agents/context.py", line 603, in _run_node_internal
    raise DynamicNodeFailError(...)
google.adk.workflow._errors.DynamicNodeFailError: Dynamic node <agent_name> failed

Corresponding persisted event (from a Postgres-backed DatabaseSessionService, field values truncated/redacted where noted):

{
  "author": "<agent_name>",
  "content": {
    "role": "model",
    "parts": [{
      "function_call": {
        "id": "call_657c83221e1643f791d271007ccc__thought__CjQBjz1rX40Rt88HYX9Ijpkt2vbI0oO3QO/0Lwxwn7SnNofgFt+7eWmYPKfpcdm3m/...(truncated, ~500 chars total)",
        "args": {"agent_name": "..."},
        "name": "transfer_to_agent"
      },
      "thought_signature": "CjQBjz1rX40Rt88HYX9Ijpkt2vbI0oO3QO_0Lwxwn7SnNofgFt-7eWmYPKfpcdm3m_...(same bytes, URL-safe base64)"
    }]
  },
  "model_version": "gemini-2.5-flash"
}

Note the function_call.id suffix (after __thought__) is byte-identical to the separately-stored thought_signature field (modulo standard vs. URL-safe base64 alphabet) — confirming this is exactly the id-embedding fallback path leaking into the persisted id.

Screenshots / Video:
N/A

Additional Context:

  • This affects any multi-agent (transfer_to_agent) topology where the same agent that issued the original tool call is later reactivated: because the current agent's own history is replayed as raw function_call/function_response parts (only other agents' events get converted to descriptive text by _present_other_agent_message in contents.py), the corrupted id is resent verbatim on every future turn — permanently breaking that conversation thread, not just the one turn.
  • We observed this as non-deterministic across otherwise-identical environments and deployments (all going through the same LiteLLM proxy) — it depends on which of the three signature-delivery shapes a given model response happens to use, not on any config difference we could identify. Other teams we've talked to have reported similarly "hit or miss" behavior, suggesting this isn't specific to our proxy setup.
  • Suggested fix: in _message_to_generate_content_response, when _THOUGHT_SIGNATURE_SEPARATOR in (tool_call.id or ""), split on it and assign only the first segment to part.function_call.id — mirroring the split already performed inside _extract_thought_signature_from_tool_call. We're carrying this as a local plugin workaround (a before_model_callback that strips the suffix from any leaked id before the request is sent) but would prefer to drop it once fixed upstream.

Minimal Reproduction Code:

import base64

from google.adk.models.lite_llm import (
    _message_to_generate_content_response,
    _THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.types.utils import ChatCompletionMessageToolCall, Function
from litellm import ChatCompletionAssistantMessage

sig_b64 = base64.b64encode(b"test_signature").decode("utf-8")

# Simulates the response shape where thought_signature is only available
# embedded in the tool call id (no extra_content / provider_specific_fields).
message = ChatCompletionAssistantMessage(
    role="assistant",
    content=None,
    tool_calls=[
        ChatCompletionMessageToolCall(
            type="function",
            id=f"call_789{_THOUGHT_SIGNATURE_SEPARATOR}{sig_b64}",
            function=Function(name="test_function", arguments="{}"),
        )
    ],
)

response = _message_to_generate_content_response(message)
fc = response.content.parts[0].function_call

print("function_call.id:", fc.id)
# Observed: "call_789__thought__dGVzdF9zaWduYXR1cmU="
# Expected: "call_789"
assert fc.id == "call_789", f"id was not stripped of thought_signature suffix: {fc.id!r}"

How often has this issue occurred?:

  • Intermittently (<50%) — depends on which of the three signature-delivery shapes a given Gemini "thinking" tool-call response happens to use; once it occurs on a given session, it recurs on every subsequent turn for that session (100% from that point forward).

Metadata

Metadata

Labels

request clarification[Status] The maintainer need clarification or more information from the authortools[Component] This issue is related to tools

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions