Skip to content

Parallel ungated tool results are lost across a tool-confirmation pause, yielding an empty model reply after approval #6732

Description

@froilan

Describe the bug

When a model turn contains a confirmation-gated function call (FunctionTool(require_confirmation=True)) in parallel with ungated function calls, the ungated siblings' results are lost across the confirmation pause. After the user approves, the resume processor (flows/llm_flows/request_confirmation.py) re-executes only the confirmed tool, leaving the sibling function_call in session history with no matching function_response. Gemini, given that dangling call, returns an empty final message — while the approved (destructive) tool HAS executed. The end user sees no answer and may retry an action that already succeeded.

To Reproduce

  1. An LlmAgent with two tools: delete_record built with require_confirmation=True, and list_records (ungated).
  2. Prompt so the model emits both calls in one turn, e.g. "Delete record X, and after deleting, list the remaining records." Gemini frequently parallelizes this into one response with both function_call parts.
  3. The run pauses with adk_request_confirmation. Observations at this point:
    • The ungated list_records does execute during the pause (its side effects are visible), but the merged function-response event — carrying the sibling's real result plus the gated tool's "This tool call requires confirmation" placeholder — is never persisted to the session. When the caller stops consuming the event stream at the confirmation event (the natural thing for a client that must wait for user input, e.g. any AG-UI/SSE bridge), the response event that _postprocess_handle_function_calls_async yields after generate_request_confirmation_event is never reached (GeneratorExit).
  4. Resume with ToolConfirmation(confirmed=True).
  5. _RequestConfirmationLlmRequestProcessor._resolve_confirmation_targets re-executes only the confirmed delete_record. Session events now contain function_call list_records with no function_response.
  6. The follow-up LLM call returns empty content ({"text": ""}).

Observed session event sequence (from a Postgres session service, ADK 2.7.0):

model: [text, function_call delete_record, thought_signature, function_call list_records, text]
model: [function_call adk_request_confirmation]   (long_running_tool_ids set)
user:  [function_response adk_request_confirmation {confirmed: true}]
model: [function_response delete_record {...success...}]   <- only the confirmed tool
model: [text ""]                                            <- empty final reply

Expected behavior

Either (a) the pause-time function-response event (sibling results + confirmation placeholder) is persisted before/with the confirmation-request event so history stays complete across the pause, or (b) the resume path re-executes (or synthesizes responses for) unresolved sibling calls from the same turn — so the post-approval continuation never sees a dangling function_call.

Screenshots

N/A (event sequence above).

Desktop:

  • OS: macOS 26 / Linux (Docker python:3.12-slim)
  • Python version: 3.12
  • ADK version: 2.7.0 (also inspected 2.5.0 — same structure)

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: gemini via Vertex AI

Additional context

Single-call confirmation flows (pause → approve/reject → resume) work correctly in 2.7.0, including the 2.6.x fixes for consumed-confirmation re-validation. The failure is specific to the parallel gated+ungated combination. Downstream we mitigate with an after_model_callback that drops ungated siblings from mixed turns before execution (the model re-issues them after the decision), but a framework-level fix would let parallel calls keep their results.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions