fix: pair every tool call with a result before the model request - #2474
fix: pair every tool call with a result before the model request#2474QuentinBisson wants to merge 3 commits into
Conversation
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>
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>
There was a problem hiding this comment.
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.
| def repair_tool_call_pairing_callback( | ||
| callback_context: CallbackContext, | ||
| llm_request: LlmRequest, | ||
| ) -> None: |
|
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? |
|
@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 :) |
Summary
before_model_callback, in both the Go and Python runtimes, so an interrupted turn no longer leaves afunction_callwithout afunction_responseask_user, or a long-running tool as awaiting a response, and any other unanswered call as having no recorded resultWithout 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_idslives 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=1go build ./adk/...,go vet ./adk/pkg/agent/ ./adk/pkg/models/,gofmt -luv run ruff check packages/kagent-adk,uv run ruff format --check19 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_paramand asserts the invariant the API enforces; both suites pair it with a guard test asserting the same check rejects unrepaired input.Notes
bedrock.gohad 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.function_responsebefore this callback runs and recovers the compaction case itself, so orphan handling is deliberately not duplicated here.Content.partsor appends newPartobjects. Recorded in the module docstring.Fixes #2277