fix(flows): pair every function call with a result in request contents - #6914
fix(flows): pair every function call with a result in request contents#6914QuentinBisson wants to merge 2 commits into
Conversation
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.
a46240f to
5d23080
Compare
|
Rebased onto main after fe32193 moved the rearrangement helpers into |
|
Hi @QuentinBisson, Thank you for putting this together and exploring the problem across #3971, #5856, and #6582. Addressing unrecoverable sessions caused by interrupted tool calls is a known pain point, and we appreciate the effort you put into investigating this. After an end-to-end evaluation of the approach and the underlying issues, we cannot merge this PR in its current form. There are architectural and behavioral limitations with injecting synthetic function responses into core request contents: 1. Role Alternation Violation on Anthropic (HTTP 400)The primary goal of pairing is avoiding provider rejections when tool calls lack results. However, in the standard interruption workflow where a user sends a follow-up message after an interrupted call (e.g.
2. Semantic History Poisoning & Schema ViolationsInjecting artificial string payloads (
3. Layering Violation in Core
|
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_callthat nofunction_responseanswers.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_responsesalready 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:
_get_contentscallspair_unanswered_function_callsin_tool_call_rearranger.py, alongside the existingdrop_orphaned_function_responses. A call the immediately following content does not answer gets a placeholder result there.adk-id was stripped is answered by a response with the same stripped id.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/unittestspasses (13981 passed, 83 skipped) on the rebased branch.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 throughanthropic_llm.content_to_message_paramand assert both halves of the provider invariant, that everytool_useblock 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.pyare updated, because a pending call now reaches the model with a placeholder.