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.
Environment
google-adk==2.0.0Bug 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
ValueErrorwhile buildingllm_request.contents— and thesession 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 requestprocessors (including the contents processor) before
before_model_callbackisinvoked (the callback fires later, inside
_call_llm_async). Abefore_model_callbackis therefore structurally unable to heal a poisoned history: the
ValueErroris thrownwhile
llm_request.contentsis 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 SSEstreaming 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 anInvocationContextcarrying one orphaned trailing function response, exactly as
BaseLlmFlow._preprocess_asyncdrives it when building
llm_request.contents.Observed output (run against
google-adk==2.0.0, Python 3.13):Root cause
google/adk/flows/llm_flows/contents.py, function_rearrange_events_for_latest_function_response(line 155). The function inspects thetrailing event (
events[-1]); if it carries function responses whose ids have no matchingfunction call anywhere in the replayed history, it raises:
(raise at line 224, inside
_rearrange_events_for_latest_function_responseat line 155).This function is called by
_get_contents(line 637), which the contentsrequest-processor's
run_asyncinvokes at line 74 while assigningllm_request.contents. The processor is one ofBaseLlmFlow.request_processors, andBaseLlmFlow._preprocess_asyncruns all request processors (line 931) before controlreaches
_call_llm_async, which is wherebefore_model_callbackruns (line 1201):Because
_preprocess_async(request processors, line 487 / line 842) runs before_call_llm_async(before_model_callback, line 1201), theValueErroris thrown beforeany user-installed
before_model_callbackis reached. Abefore_model_callback-basedworkaround 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.contentsshould be resilient to a single orphaned functionresponse. Reasonable options:
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.
non-fatal event or a clearly-typed exception) so callers can heal the in-memory history
and retry, rather than a bare
ValueErrorraised deep in contents construction thataborts 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.contentsconstruction. The model is never called for theturn. 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
ValueErrorgenerically). The session's stored events keep theimbalance forever, so every subsequent turn over the same session re-raises identically.
The user sees an empty turn with no recovery path.
Related
docs/upstream-reports/03-progressive-sse-task-dispatch.mddocuments 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.
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 atdocs/upstream-reports/01-mixed-turn-fc-drop.md. Both defects poison sessions through anunbalanced 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 a400.