fix(llminternal): pair every function call with a result in request contents - #1405
Open
QuentinBisson wants to merge 2 commits into
Open
fix(llminternal): pair every function call with a result in request contents#1405QuentinBisson wants to merge 2 commits into
QuentinBisson wants to merge 2 commits into
Conversation
…ontents 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 LongRunningToolIDs, which lives on the event and does not survive the conversion to contents. Mirrors the same change in adk-python contents assembly.
A provider that requires strict pairing also requires the results to precede any other block in the message that carries them. 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.
QuentinBisson
marked this pull request as ready for review
August 26, 2026 13:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, and a provider that requires strict pairing rejects the whole conversation. The OpenAI Responses path in this repo sends the dangling call as a
function_callitem with nofunction_call_output, which the API rejects. Anthropic, used through a custom model, answerstool_use ids were found without tool_result blocks immediately after. Either way the session stays unusable until it is deleted.No issue exists for the Go side. The same failure is reported against adk-python in google/adk-python#5856, #3971 and #6582.
Change
Pair every function call with a result while the request contents are assembled, in
buildContentsDefault. A call the immediately following content does not answer gets a placeholder result there.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
LongRunningToolIDs, which lives on the event and does not survive the conversion to contents.A conversation whose calls are all answered is returned unchanged.
The mirror change for adk-python is google/adk-python#6914.
Testing plan
go test ./... -count=1— 158 packages, all pass.go build ./...,go vet ./internal/llminternal/,gofmt -l.internal/llminternal/tool_pairing_test.godrives the request processor over eight histories: 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, calls without an id, and a healthy history that must come back unchanged. Each case also asserts the provider invariant, that the content after a call answers every call it carries; a guard test asserts the same check rejects unrepaired input.Two
TestContentsRequestProcessor_IncludeContents/agentTransferfixtures end on an unanswered call. They now expect the placeholder result instead of the synthetic continuation turn.