Skip to content

fix: pair every tool call with a result before the model request - #2474

Open
QuentinBisson wants to merge 3 commits into
kagent-dev:mainfrom
QuentinBisson:fix/2277-tool-call-pairing
Open

fix: pair every tool call with a result before the model request#2474
QuentinBisson wants to merge 3 commits into
kagent-dev:mainfrom
QuentinBisson:fix/2277-tool-call-pairing

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • pair every tool call with a result in a before_model_callback, in both the Go and Python runtimes, so an interrupted turn no longer leaves a function_call without a function_response
  • describe a call ADK holds open for a human approval, ask_user, or a long-running tool as awaiting a response, and any other unanswered call as having no recorded result
  • repair the request rather than the store, so recorded history stays intact, broken sessions heal on their next turn with no migration, and one implementation covers every session service and provider
  • log when a request had to be repaired

Without this, the dangling call is replayed on every later turn and providers that require strict pairing reject the whole conversation (tool_use ids were found without tool_result blocks immediately after), leaving the session unusable until it is deleted.

The two placeholders differ because 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. long_running_tool_ids lives on the event and does not survive the conversion to contents, so it is read from the session.

Pairing is checked positionally, against the immediately following content, because that is the invariant the provider enforces.

Validation

  • uv run pytest packages/kagent-adk/tests/unittests (395 passed)
  • go test ./adk/pkg/agent/ -count=1
  • go build ./adk/..., go vet ./adk/pkg/agent/ ./adk/pkg/models/, gofmt -l
  • uv run ruff check packages/kagent-adk, uv run ruff format --check

19 Python and 13 Go tests cover the interrupted turn, the partially answered turn, the pending-approval case in both directions, calls with no id, and the no-op property on healthy history. The Python acceptance test converts repaired contents through google-adk's content_to_message_param and asserts the invariant the API enforces; both suites pair it with a guard test asserting the same check rejects unrepaired input.

Notes

  • Python's Anthropic and Bedrock paths and Go's bedrock.go had no pairing repair; the other converters already have their own copy and are left in place, since they find a result present once contents are paired.
  • ADK removes an orphaned function_response before this callback runs and recovers the compaction case itself, so orphan handling is deliberately not duplicated here.
  • ADK's per-request content copies are shallow in Python, so this code only assigns Content.parts or appends new Part objects. Recorded in the module docstring.

Fixes #2277

A tool call and its result are persisted as two separate session events.
If a turn ends between them, the session keeps a function call with no
matching response, and replaying that history makes providers requiring
strict pairing reject the whole conversation. Anthropic returns 400
"tool_use ids were found without tool_result blocks immediately after"
on every later turn, leaving the session unusable until it is deleted.

Repair the model request rather than the store: supply a placeholder
result for a call that has none, and drop a result whose call is gone.
Pairing is checked against the immediately following content, which is
the invariant the provider enforces. Recorded history is untouched, so
the UI still shows what happened and sessions already broken heal on
their next turn without a migration.

A conversation whose calls are all answered is left unchanged, and any
conversation this does alter is one the provider would have rejected.

Fixes kagent-dev#2277

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@github-actions github-actions Bot added the bug Something isn't working label Aug 18, 2026
Read long_running_tool_ids from the session so a call ADK is holding open
for a human approval, ask_user, or a long-running tool is described as
awaiting a response rather than as having returned nothing.

Drop the orphaned-response pass. ADK removes an orphaned function response
before the callback runs, and recovers the compaction case by re-injecting
the missing call event, so the pass could only discard a real result.

Match responses to calls by consuming ids one at a time so several calls
with no id in one turn each get their own result.

Log when a request had to be repaired.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Aug 18, 2026
@QuentinBisson
QuentinBisson marked this pull request as ready for review August 18, 2026 23:45
@QuentinBisson
QuentinBisson requested review from a team and supreme-gg-gg as code owners August 18, 2026 23:45
Copilot AI lite review requested due to automatic review settings August 18, 2026 23:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes interrupted-turn session corruption by repairing tool call/result adjacency at request-build time (instead of migrating stored history), ensuring providers with strict invariants (notably Anthropic/Bedrock) no longer reject replayed conversations that contain dangling tool calls.

Changes:

  • Add request-time “tool call pairing repair” callbacks in both the Python and Go ADK runtimes to synthesize placeholder tool results when an immediate next-turn result is missing.
  • Distinguish intentionally pending tool calls (approval / ask_user / long-running) from genuinely missing results via a different placeholder message.
  • Add unit/acceptance-style tests in both languages covering interrupted turns, partial answers, pending approvals, id-less calls, and “no-op on healthy history”.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
python/packages/kagent-adk/src/kagent/adk/_tool_pairing.py Implements Python before-model callback to enforce strict tool call/result adjacency via synthesized placeholder responses.
python/packages/kagent-adk/src/kagent/adk/types.py Wires the new pairing-repair callback into the agent’s before_model_callback pipeline (runs last).
python/packages/kagent-adk/tests/unittests/test_tool_pairing.py Adds coverage for missing/pending tool results and validates the Anthropic adjacency invariant on repaired contents.
go/adk/pkg/agent/tool_pairing.go Implements Go before-model callback to synthesize missing function responses and log repairs.
go/adk/pkg/agent/tool_pairing_test.go Adds tests for repair behavior and adjacency invariant across interrupted / partial / pending cases.
go/adk/pkg/agent/agent.go Wires the Go pairing-repair callback into agent creation (runs last).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +103 to +106
def repair_tool_call_pairing_callback(
callback_context: CallbackContext,
llm_request: LlmRequest,
) -> None:
@supreme-gg-gg

Copy link
Copy Markdown
Contributor

I haven't looked at the code yet, but this seems like an upstream responsibility at the framework level (at least in the long run). Have you checked if there's any discussion / issue / PR relating to this in either python or go ADK?

@QuentinBisson

Copy link
Copy Markdown
Contributor Author

@supreme-gg-gg you're right.

I asked my agent to take a look and this has been raised upstream several times. google/adk-python#5856 is the same failure (orphaned function_call after interruption, 400 from Claude's strict pairing); an ADK maintainer replied that the recommended approach is to handle it in the callback feature, then the issue was stale-closed. Generic healing PRs in core were closed unmerged (google/adk-python#4055, #4056, #6587), and related pairing fixes are still churning (#6733, #6752, #6764). The Go side has had its own rearrangement bugs (google/adk-go#761).

If you prefer that I close this PR in favor or fixing it the the adk directly that's totally fine with me :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interrupted turn permanently corrupts session history for Anthropic models (dangling tool_use leads to 400 on every later turn)

3 participants