Skip to content

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

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

fix(llminternal): pair every function call with a result in request contents#1405
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

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_call item with no function_call_output, which the API rejects. Anthropic, used through a custom model, answers tool_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.

  • 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 client 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 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.go drives 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/agentTransfer fixtures end on an unanswered call. They now expect the placeholder result instead of the synthetic continuation turn.

…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.
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.

1 participant