Skip to content

fix(flows): pair every function call with a result in request contents - #6914

Open
QuentinBisson wants to merge 2 commits into
google:mainfrom
QuentinBisson:fix/pair-unanswered-function-calls
Open

fix(flows): pair every function call with a result in request contents#6914
QuentinBisson wants to merge 2 commits into
google:mainfrom
QuentinBisson:fix/pair-unanswered-function-calls

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Link to Issue or Description of Change

Problem

A function call and its result are two separate session events. When a turn ends between them (a restart, an OOM kill, a client disconnect, a cancellation), the session keeps a function_call that no function_response answers.

That history is replayed on every later turn, so the session does not recover on its own. A provider that requires strict pairing rejects the whole conversation: Anthropic answers tool_use ids were found without tool_result blocks immediately after, and the session stays unusable until it is deleted.

_drop_orphaned_function_responses already covers the mirror case, a response with no call. The unanswered call has no owner.

Change

Pair every function call with a result while the request contents are assembled, in _get_contents. A call the immediately following content does not answer gets a placeholder result there.

  • The stored events are untouched, so recorded history stays intact and a session that is already broken heals on its next turn with no migration.
  • It sits above the session service, so one implementation covers every store and every provider.
  • Ids are read after the conversion, so a call whose adk- id was stripped is answered by a response with the same stripped id.
  • Pairing is checked positionally, against the immediately following content, because that is the invariant the provider enforces.
  • On a turn that is already partly answered, the placeholder joins the leading run of responses. Anthropic requires the results to precede any other block in the message, so appending after a trailing text part would be rejected for the same reason the missing result was.

A call the framework holds open (a long-running tool, an approval, a request for user input) is described as awaiting a response, not as having returned nothing. The difference changes what the model does next: told a tool returned nothing, it reissues the call or proceeds without it; told the call is still awaiting a response, it can wait. Those ids come from long_running_tool_ids, which lives on the event and does not survive the conversion to contents. Only a call with no recorded response is logged, so a call that is merely awaiting an answer stays quiet across the turns it spans.

A conversation whose calls are all answered is returned unchanged.

The mirror change for adk-go is google/adk-go#1405.

Testing plan

uv run pytest tests/unittests — 13067 passed, 2 pre-existing failures in test_import_loading.py that are present on main as well.

Seven new cases in tests/unittests/flows/llm_flows/test_contents_function.py: the interrupted turn at the end of the history and in the middle, the partially answered parallel turn, the same turn with a trailing text part, the call held open in both directions, and calls without an id. Two more convert the repaired contents through anthropic_llm.content_to_message_param and assert both halves of the provider invariant, that every tool_use block is answered by the next message and that the results precede any other block in it; a guard test asserts the same check rejects unrepaired input.

Two assertions in tests/unittests/apps/test_compaction.py are updated, because a pending call now reaches the model with a placeholder.

A function call and its result are two separate session events. When a
turn ends between them (a restart, an OOM kill, a disconnect, a
cancellation), the session keeps a function_call that no
function_response answers.

That history is replayed on every later turn, and a provider that
requires strict pairing rejects the whole conversation. Anthropic
answers "tool_use ids were found without tool_result blocks immediately
after", and the session stays unusable until it is deleted.

Give every unanswered call a placeholder result in the immediately
following content, while the request contents are assembled. The stored
events are left untouched, so recorded history stays intact and a
session that is already broken heals on its next turn without a
migration. It sits above the session service, so it covers every store
and every provider.

A call the framework is holding open (a long-running tool, an approval,
a request for user input) is described as awaiting a response, not as
having returned nothing: told a tool returned nothing, the model
reissues the call or proceeds without it; told the call is still
awaiting a response, it can wait. Those ids are read from
long_running_tool_ids, which lives on the event and does not survive the
conversion to contents.
Anthropic requires every tool result to precede any other block in the
message that carries it. Appending the placeholder to the end of a
partially answered turn put it after a trailing text part, which is
rejected for the same reason the missing result was.

Insert the placeholder into the leading run of responses instead, and
warn only for a call with no recorded response, so a call that is merely
awaiting an answer stays quiet across the turns it spans.
@QuentinBisson
QuentinBisson marked this pull request as ready for review August 26, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants