Skip to content

contents request-processor (_rearrange_events_for_latest_function_response) raises ValueError on an orphaned function response, permanently poisoning the session — raise happens before before_model_callback, so no user hook can intercept it #6582

Description

@kenwilly

Environment

  • Python 3.13
  • google-adk==2.0.0

Bug description

If a session ever persists a function response event whose matching function
call event is absent from the branch-filtered history ADK replays, then ADK's contents
request-processor raises ValueError while building llm_request.contents — and the
session is permanently poisoned: every subsequent turn replays the same history and
re-raises, so the agent returns nothing to the user with no recoverable path.

The raise happens inside BaseLlmFlow._preprocess_async, which runs the request
processors (including the contents processor) before before_model_callback is
invoked (the callback fires later, inside _call_llm_async). A before_model_callback
is therefore structurally unable to heal a poisoned history: the ValueError is thrown
while llm_request.contents is still being assembled, before any user hook runs.

How a session gets an orphaned function response in the first place is a separate,
upstream producer defect — see the companion report at
docs/upstream-reports/03-progressive-sse-task-dispatch.md: under progressive SSE
streaming the chat wrapper can synthesize and persist a task-delegation function
response while never persisting the matching function call, which is the dominant
observed producer of exactly this poisoned state. This report covers the consumption
side: the contents-processor raise that turns any orphaned FR into a dead session.

Reproduction

A self-contained Python repro drives ADK's real contents request-processor (the
production singleton, contents.request_processor) against an InvocationContext
carrying one orphaned trailing function response, exactly as BaseLlmFlow._preprocess_async
drives it when building llm_request.contents.

"""
Minimal repro: ADK 2.0.0 orphaned function-response poisoning.

Run:  cd <repo with google-adk==2.0.0> && python repro_orphaned_fr.py
Expected: prints the ValueError ADK raises while building llm_request.contents.
"""

from __future__ import annotations

import asyncio
import traceback

from google.adk.agents import LlmAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.flows.llm_flows import contents as _contents
from google.adk.models.llm_request import LlmRequest
from google.adk.sessions import InMemorySessionService
from google.adk.sessions.session import Session
from google.genai import types


def _fc(name: str, fc_id: str) -> types.Part:
    return types.Part(function_call=types.FunctionCall(name=name, args={}, id=fc_id))


def _fr(name: str, fr_id: str) -> types.Part:
    return types.Part(
        function_response=types.FunctionResponse(
            name=name, response={"result": "ok"}, id=fr_id
        )
    )


def _txt(s: str) -> types.Part:
    return types.Part(text=s)


def _event(author: str, parts: list[types.Part]):
    from google.adk.events.event import Event

    return Event(
        author=author,
        content=types.Content(role=author, parts=parts),
    )


def _make_invocation_context(events: list) -> InvocationContext:
    agent = LlmAgent(
        name="root_coord",
        model="gemini-2.0-flash",
        instruction="Coord.",
        tools=[],
    )
    session = Session(
        id="repro-session",
        app_name="repro-app",
        user_id="u1",
        events=list(events),
    )
    return InvocationContext(
        invocation_id="inv-1",
        agent=agent,
        session=session,
        session_service=InMemorySessionService(),
        end_invocation=False,
    )


async def _run_repro() -> None:
    # A history carrying an orphaned trailing function RESPONSE with no
    # matching function CALL. ADK's _rearrange_events_for_latest_function_response
    # inspects events[-1] and raises on this exact shape.
    events = [
        _event("user", [_txt("hi")]),
        _event("model", [_fc("tool_a", "id-call")]),
        _event("user", [_fr("tool_a", "id-call")]),
        _event("model", [_txt("answer")]),
        _event("user", [_fr("ghost_tool", "orphan-1")]),  # trailing orphan FR
    ]
    ic = _make_invocation_context(events)

    # Drive ADK's REAL contents request-processor exactly as
    # BaseLlmFlow._preprocess_async does when building llm_request.contents.
    print("Driving ADK contents.request_processor against an orphaned-FR history...\n")
    req = LlmRequest()
    try:
        async for _ in _contents.request_processor.run_async(ic, req):
            pass
        print("NO RAISE — the bug may be fixed upstream; re-check on this ADK version.")
    except ValueError as exc:
        print("RAISED ValueError (the orphaned-function-response poison):\n")
        print(f"  type: {type(exc).__module__}.{type(exc).__name__}")
        print(f"  message: {exc}\n")
        print("Traceback (ADK contents processor, _preprocess_async path):")
        print("----")
        print(traceback.format_exc())
        print("----")
        print(
            "BUG CONFIRMED: a persisted function RESPONSE with no matching function\n"
            "CALL makes _rearrange_events_for_latest_function_response raise while\n"
            "building llm_request.contents. This runs in _preprocess_async (request\n"
            "processors), which BaseLlmFlow executes BEFORE before_model_callback —\n"
            "so no before_model hook can heal it. The session is permanently poisoned:\n"
            "every later turn replays the same history and re-raises."
        )


if __name__ == "__main__":
    asyncio.run(_run_repro())

Observed output (run against google-adk==2.0.0, Python 3.13):

Driving ADK contents.request_processor against an orphaned-FR history...

RAISED ValueError (the orphaned-function-response poison):

  type: builtins.ValueError
  message: No function call event found for function responses ids: {'orphan-1'}

Traceback (ADK contents processor, _preprocess_async path):
----
Traceback (most recent call last):
  File "/tmp/repro_orphaned_fr.py", line 95, in _run_repro
    async for _ in _contents.request_processor.run_async(ic, req):
        pass
  File ".../google/adk/flows/llm_flows/contents.py", line 74, in run_async
    llm_request.contents = _get_contents(
  File ".../google/adk/flows/llm_flows/contents.py", line 637, in _get_contents
    result_events = _rearrange_events_for_latest_function_response(
        filtered_events
    )
  File ".../google/adk/flows/llm_flows/contents.py", line 224, in _rearrange_events_for_latest_function_response
    raise ValueError(
        ...
    )
ValueError: No function call event found for function responses ids: {'orphan-1'}

----
BUG CONFIRMED: a persisted function RESPONSE with no matching function
CALL makes _rearrange_events_for_latest_function_response raise while
building llm_request.contents. This runs in _preprocess_async (request
processors), which BaseLlmFlow executes BEFORE before_model_callback —
so no before_model hook can heal it. The session is permanently poisoned:
every later turn replays the same history and re-raises.

Root cause

google/adk/flows/llm_flows/contents.py, function
_rearrange_events_for_latest_function_response (line 155). The function inspects the
trailing event (events[-1]); if it carries function responses whose ids have no matching
function call anywhere in the replayed history, it raises:

  if function_call_event_idx == -1:
    logger.debug(
        'No function call event found for function responses ids: %s in'
        ' event list: %s',
        function_responses_ids,
        events,
    )
    raise ValueError(
        'No function call event found for function responses ids:'
        f' {function_responses_ids}'
    )

(raise at line 224, inside _rearrange_events_for_latest_function_response at line 155).

This function is called by _get_contents (line 637), which the contents
request-processor's run_async invokes at line 74 while assigning
llm_request.contents. The processor is one of BaseLlmFlow.request_processors, and
BaseLlmFlow._preprocess_async runs all request processors (line 931) before control
reaches _call_llm_async, which is where before_model_callback runs (line 1201):

  async def _preprocess_async(self, invocation_context, llm_request):
    ...
    # Runs processors.
    for processor in self.request_processors:           # line 931 — contents processor raises HERE
      async with Aclosing(processor.run_async(invocation_context, llm_request)) as agen:
        async for event in agen:
          yield event
  async def _call_llm_async(self, invocation_context, llm_request, model_response_event):
    ...
    if response := await self._handle_before_model_callback(   # line 1201 — before_model runs HERE
        invocation_context, llm_request, model_response_event
    ):
      ...

Because _preprocess_async (request processors, line 487 / line 842) runs before
_call_llm_async (before_model_callback, line 1201), the ValueError is thrown before
any user-installed before_model_callback is reached. A before_model_callback-based
workaround for the inverse defect (an orphaned function call padded with a synthetic
response) cannot help here: it sits on the wrong side of the raise.

Expected behaviour

Building llm_request.contents should be resilient to a single orphaned function
response. Reasonable options:

  • Drop the orphaned function-response part(s) from the replayed contents (the model
    simply never sees a result it never asked for), build the request from the surviving
    events, and continue — rather than raising and killing the session.
  • Surface the orphaned-response condition as a structured, recoverable signal (e.g. a
    non-fatal event or a clearly-typed exception) so callers can heal the in-memory history
    and retry, rather than a bare ValueError raised deep in contents construction that
    aborts the whole turn.

Either keeps the session usable; the current behaviour makes any session that ever
acquires an orphaned FR permanently dead.

Observed behaviour

The raise aborts llm_request.contents construction. The model is never called for the
turn. The exception propagates up through the flow with no indication of which session or
which response id is at fault (the message carries the ids, but the raise is anonymous to
any caller that catches ValueError generically). The session's stored events keep the
imbalance forever, so every subsequent turn over the same session re-raises identically.
The user sees an empty turn with no recovery path.

Related

  • Producer defect (companion report): docs/upstream-reports/03-progressive-sse-task-dispatch.md
    documents the dominant observed producer of an orphaned function response: under
    progressive SSE streaming, the chat wrapper synthesizes and persists a task-delegation
    function response while never persisting the matching function call. Fixing
    this consumption-side raise is necessary regardless, because any path that lands an
    orphaned FR (a turn that died mid-dispatch, a branch/isolation filter that excluded the
    call, a producer bug) hits this same fatal raise.
  • The inverse direction — an orphaned function call (N calls, < N responses) — is
    rejected by Gemini at the model boundary as
    400 INVALID_ARGUMENT: Please ensure that the number of function response parts is equal to the number of function call parts, and is covered by the companion report at
    docs/upstream-reports/01-mixed-turn-fc-drop.md. Both defects poison sessions through an
    unbalanced FC/FR history; this one fails earlier (during contents build) and louder (a
    raised ValueError), while the mixed-turn drop fails at the model call with a 400.

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent engine[Component] This issue is related to Vertex AI Agent Engine

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions