Skip to content

Live tool function_response lacks live_session_id, causing orphan response ValueError after reconnect/history rebuild #5702

@smwitkowski

Description

@smwitkowski

🔴 Required Information

Describe the Bug:

In ADK Live mode, a model-generated function_call event can be persisted with live_session_id, while the matching ADK-generated function_response event is persisted without live_session_id.

That mixed metadata causes ADK history reconstruction to fail if a later Live run/reconnect starts from a session where the latest relevant event is that function response.

The failing shape is:

function_call event:
  author=<agent>
  role=model
  function_call.id=<id>
  live_session_id=<live session id>

function_response event:
  author=<same agent>
  role=user
  function_response.id=<same id>
  live_session_id=None

During _get_contents(), the Live-scoped model function_call is treated as context and converted into text. The matching function_response remains structured. _rearrange_events_for_latest_function_response() then sees a structured response but cannot find the structured matching call, and raises:

ValueError: No function call event found for function responses ids: {...}

This appears to be caused by the Live tool response path in google/adk/flows/llm_flows/functions.py: handle_function_calls_live() receives the original function_call_event with live_session_id, but __build_response_event() creates the matching function_response event without preserving that Live session metadata.

Steps to Reproduce:

  1. Install ADK:

    pip install google-adk==1.32.0
  2. Save the script below as repro_adk_live_organic_tool_response_orphan.py.

  3. Run it:

    python repro_adk_live_organic_tool_response_orphan.py
  4. Observe that ADK organically creates:

    • a function_call event with live_session_id
    • a matching function_response event without live_session_id
  5. Observe that a subsequent _get_contents() history rebuild raises the orphan function response ValueError.

Expected Behavior:

A Live tool call and its matching tool response should remain pairable during history reconstruction.

Either:

  • the Live-generated function_call and ADK-generated function_response should carry consistent Live metadata, or
  • the history builder should avoid converting away the structured function_call while leaving its matching structured function_response behind.

In practice, reconnecting/resuming a Live session after a tool response should not permanently poison the session history.

Observed Behavior:

ADK raises:

ValueError: No function call event found for function responses ids: {'b00a1bcc-42b5-4dc4-9ba2-11c15816b8b1'}

The function call is not absent from the session. It exists, but was converted out of structured form during history rebuild because it had live_session_id. The matching function response remains structured because it did not have
live_session_id.

Environment Details:

  • ADK Library Version: google-adk 1.32.0
  • Desktop OS: macOS
  • Python Version: Python 3.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: gemini-live-2.5-flash-native-audio

🟡 Optional Information

Regression:

N/A. We have reproduced this on google-adk 1.32.0.

Logs:

Expected output from the repro:

[generated events]
[event] {
  "author": "root_agent",
  "role": "model",
  "live_session_id": "live-session-1",
  "function_calls": [
    {
      "id": "b00a1bcc-42b5-4dc4-9ba2-11c15816b8b1",
      "name": "choice_group",
      "args": {
        "question": "Which option should we show?"
      }
    }
  ],
  "function_responses": []
}
[event] {
  "author": "root_agent",
  "role": "user",
  "live_session_id": null,
  "function_calls": [],
  "function_responses": [
    {
      "id": "b00a1bcc-42b5-4dc4-9ba2-11c15816b8b1",
      "name": "choice_group",
      "response": {
        "result": {
          "rich_content": {
            "type": "choice_group",
            "question": "Which option should we show?",
            "choices": [
              {
                "text": "Tell me about Scarlet Lady"
              },
              {
                "text": "What is included in the fare?"
              },
              {
                "text": "How do dining reservations work?"
              }
            ]
          }
        }
      }
    }
  ]
}

[history rebuild]
RAISED ValueError: No function call event found for function responses ids: {'b00a1bcc-42b5-4dc4-9ba2-11c15816b8b1'}

Additional Context:

We first observed this as a follow-on failure after Agent Engine/Live reconnect/resumption. The client reconnects, the same persisted ADK session history is used, and bidi_stream_query fails repeatedly because every new stream trips the same history reconstruction error.

This is separate from transport-level Live TTL/session-resumption behavior. The transport can reconnect successfully; the failure happens when ADK rebuilds model input from persisted events whose latest relevant boundary is a Live tool response.

The relevant source boundaries appear to be:

  • google/adk/flows/llm_flows/functions.py
    • handle_function_calls_live()
    • _execute_single_function_call_live()
    • __build_response_event()
    • merge_parallel_function_response_events()
  • google/adk/flows/llm_flows/contents.py
    • _is_other_agent_reply()
    • _rearrange_events_for_latest_function_response()

Suggested fix direction:

  1. In the Live function-call path, preserve the originating function_call_event.live_session_id on matching function response events.
  2. If parallel function response events are merged, preserve the base response event's live_session_id on the merged event.
  3. Add a regression test where:
    • a Live model function_call has live_session_id
    • ADK builds the matching function_response
    • history reconstruction via _get_contents(... preserve_function_call_ids=True) does not raise

Conceptually:

# functions.py, Live tool response path
function_response_event = __build_response_event(
    tool,
    function_response,
    tool_context,
    invocation_context,
)
function_response_event.live_session_id = function_call_event.live_session_id

and for merged parallel tool responses:

merged_event = Event(
    invocation_id=base_event.invocation_id,
    author=base_event.author,
    branch=base_event.branch,
    content=types.Content(role='user', parts=merged_parts),
    actions=merged_actions,
    live_session_id=base_event.live_session_id,
)

Minimal Reproduction Code:

This repro does not call Gemini or Agent Engine. It simulates only the inbound Live model response that Gemini/ADK's Live connection would provide: an LlmResponse containing a function_call and a live_session_id.

Everything after that is ADK's normal Live path:

  1. BaseLlmFlow._postprocess_live() finalizes the model function_call event.
  2. ADK's Live tool handler runs a real FunctionTool.
  3. ADK builds the matching function_response event.
  4. The generated events are appended to an ADK session.
  5. A later history rebuild raises the orphan response ValueError.
from __future__ import annotations

import asyncio
import json
from typing import Any

from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.run_config import RunConfig
from google.adk.events.event import Event
from google.adk.flows.llm_flows.base_llm_flow import BaseLlmFlow
from google.adk.flows.llm_flows.contents import _get_contents
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.function_tool import FunctionTool
from google.genai import types


APP_NAME = "organic-live-history-repro"
USER_ID = "user"
SESSION_ID = "session"
AGENT_NAME = "root_agent"
INVOCATION_ID = "invocation-1"
CALL_ID = "b00a1bcc-42b5-4dc4-9ba2-11c15816b8b1"
LIVE_SESSION_ID = "live-session-1"


def choice_group(question: str) -> dict[str, Any]:
    return {
        "result": {
            "rich_content": {
                "type": "choice_group",
                "question": question,
                "choices": [
                    {"text": "Tell me about Scarlet Lady"},
                    {"text": "What is included in the fare?"},
                    {"text": "How do dining reservations work?"},
                ],
            }
        }
    }


def event_summary(event: Event) -> dict[str, Any]:
    parts = event.content.parts if event.content and event.content.parts else []
    return {
        "author": event.author,
        "role": event.content.role if event.content else None,
        "live_session_id": event.live_session_id,
        "function_calls": [
            part.function_call.model_dump(exclude_none=True, mode="json")
            for part in parts
            if part.function_call
        ],
        "function_responses": [
            part.function_response.model_dump(exclude_none=True, mode="json")
            for part in parts
            if part.function_response
        ],
    }


async def main() -> None:
    session_service = InMemorySessionService()
    session = await session_service.create_session(
        app_name=APP_NAME,
        user_id=USER_ID,
        session_id=SESSION_ID,
    )

    tool = FunctionTool(choice_group)
    agent = LlmAgent(
        name=AGENT_NAME,
        model="gemini-live-2.5-flash-native-audio",
        tools=[tool],
    )

    invocation_context = InvocationContext(
        session_service=session_service,
        invocation_id=INVOCATION_ID,
        agent=agent,
        session=session,
        run_config=RunConfig(),
    )

    llm_request = LlmRequest()
    llm_request.tools_dict[tool.name] = tool

    # This is the only simulated input: the shape produced when Gemini Live
    # asks to call a tool. ADK's Live connection stamps model responses with
    # live_session_id before BaseLlmFlow sees them.
    live_model_tool_call = LlmResponse(
        model_version="gemini-live-2.5-flash-native-audio",
        live_session_id=LIVE_SESSION_ID,
        content=types.Content(
            role="model",
            parts=[
                types.Part(
                    function_call=types.FunctionCall(
                        id=CALL_ID,
                        name="choice_group",
                        args={"question": "Which option should we show?"},
                    )
                )
            ],
        ),
    )

    flow = BaseLlmFlow()
    base_model_event = Event(
        invocation_id=INVOCATION_ID,
        author=AGENT_NAME,
    )

    generated_events: list[Event] = []
    async for event in flow._postprocess_live(
        invocation_context,
        llm_request,
        live_model_tool_call,
        base_model_event,
    ):
        generated_events.append(event)
        await session_service.append_event(session=session, event=event)

    print("[generated events]")
    for event in generated_events:
        print(json.dumps(event_summary(event), indent=2))

    print("\n[history rebuild]")
    try:
        contents = _get_contents(
            current_branch=None,
            events=session.events,
            agent_name=AGENT_NAME,
            preserve_function_call_ids=True,
        )
    except Exception as exc:
        print(f"RAISED {type(exc).__name__}: {exc}")
        return

    print(f"OK contents={len(contents)}")
    for content in contents:
        print(json.dumps(content.model_dump(exclude_none=True, mode="json")))


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

How often has this issue occurred?:

  • Always (100%) for the deterministic local repro above.
  • In deployed Live/Agent Engine usage, it is timing-dependent: it appears when a reconnect/resume or new stream starts from history where the latest relevant persisted boundary is the ADK-created tool response.

Metadata

Metadata

Assignees

Labels

live[Component] This issue is related to live, voice and video chatwip[Status] This issue is being worked on. Either there is a pending PR or is planned to be fixed

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions