Skip to content

Late long-running tool completion is buried mid-history by response rearrangement — models answer the stale exchange left at the tail #1181

Description

@dmora

🔴 Required Information

Describe the Bug:

When a long-running (async) tool's final FunctionResponse arrives as the latest session event — after other, unrelated tool exchanges have happened — the request contents sent to the model end on a stale exchange instead of the completion.

buildContentsDefault runs two rearrangement passes back-to-back:

  1. rearrangeEventsForLatestFunctionResponse handles the late response correctly: it finds the matching call, merges intermediate + final responses, preserves the intervening unrelated tool events (the deliberate fix from fix: preserve unrelated function events #767 for Function response rearrangement can drop unrelated tool events from history #761), and appends the merged response last. At this point the contents are right.
  2. rearrangeEventsForFunctionResponsesInHistory then rebuilds the list attaching every response event immediately after its matching call — including the merged response pass 1 just placed at the tail. The completion is dragged back next to its (much earlier) call, and whatever unrelated exchange happened last is left as the final content.

Models act on the final contents, so they answer the stale exchange and never see the completion.

Steps to Reproduce:

  1. Build a session history where a long-running tool's final response is the latest event, with an unrelated tool exchange in between (this is exactly the event sequence of the existing test case "Rearrangement preserves unrelated function events" in contents_processor_test.go):
    1. user:  text "Run long process and search"
    2. model: FunctionCall  A (long_running_tool, id=long_call_123)
    3. user:  FunctionResponse A-intermediate ("processing")
    4. model: FunctionCall  B (search_tool, id=call_123)
    5. user:  FunctionResponse B ("results...")
    6. user:  FunctionResponse A-final ("completed")   ← latest event
    
  2. Run the contents pipeline (ContentsRequestProcessor / buildContentsDefault) over it.
  3. Inspect the resulting LLMRequest.Contents ordering.

Expected Behavior:

The latest function response remains the final content. The unrelated B exchange stays preserved (the property #767 established) — before the completion rather than after it:

user text · B-call · B-resp · A-call · A-final(merged)

Observed Behavior:

The completion is buried mid-history and the stale B exchange is the tail:

user text · A-call · A-final(merged) · B-call · B-resp   ← model answers B-resp

Unit-level demonstration on current main (cmp diff, -want +got, from a regression test we can contribute):

  []*genai.Content{
  	&{... "Plan how to add feature Q" ..., Role: "user"},
+ 	&{Parts: {&{FunctionCall: plan_tool ...}}, Role: "model"},     ← completion buried here
+ 	&{Parts: {&{FunctionResponse: plan_tool completed ...}}, Role: "user"},
  	&{Parts: {&{FunctionCall: status_tool ...}}, Role: "model"},
  	&{Parts: {&{FunctionResponse: status "running, 21s" ...}}, Role: "user"},
- 	&{Parts: {&{FunctionCall: plan_tool ...}}, Role: "model"},     ← where it must be
- 	&{Parts: {&{FunctionResponse: plan_tool completed ...}}, Role: "user"},
  }

Real-world impact: in our production multi-agent supervisor this made three different models consistently blind to long-running tool completions — each re-summarized the stale status exchange at the tail; one literally repeated a "21 seconds elapsed, still running" status while the completed 5.5 KB result sat merged mid-history, immediately after a call issued minutes earlier.

Environment Details:

  • ADK Library Version: v1.5.0, v2.0.0, and current main (the three functions involved are byte-identical across all three)
  • OS: macOS (logic bug — OS-independent)
  • Go Version: go 1.25

Model Information:

  • Which model is being used: gemini-3.1-pro (native path); also reproduced with DeepSeek V3.2 and Kimi K2 through an OpenAI-compatible path — the contents are already wrong before any provider adapter runs, so the bug is model-independent.

🟡 Optional Information

Regression:

Partially — the burying behavior arrived with #767 (2026-05-28). Before #767, the same flow dropped the unrelated intervening events entirely (that data loss was #761). So no prior version handled this history correctly: pre-#767 lost events, post-#767 buries the completion. The fix proposed below is the first state with both properties (nothing dropped and the completion stays last).

Logs:

(see the cmp diff under Observed Behavior; happy to attach full pre/post contents dumps)

Minimal Reproduction Code:

Add this row to TestContentsRequestProcessor_Rearrange in internal/llminternal/contents_processor_test.go on unpatched main — it fails with the diff shown above (fixtures follow the file's existing style):

{
	name: "Late async completion stays the final content",
	events: []*session.Event{
		{Author: "user", LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("Plan how to add feature Q", "user")}},
		{Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcAsyncPlan, "model")}},
		{Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frAsyncPlanAck, "user")}},
		{Author: agentName, LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("I dispatched the planning task.", "model")}},
		{Author: "user", LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("What is the status?", "user")}},
		{Author: agentName, LLMResponse: model.LLMResponse{Content: NewContentFromFunctionCall(fcAsyncStatus, "model")}},
		{Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frAsyncStatus, "user")}},
		{Author: agentName, LLMResponse: model.LLMResponse{Content: genai.NewContentFromText("Still running.", "model")}},
		{Author: "user", LLMResponse: model.LLMResponse{Content: NewContentFromFunctionResponse(frAsyncPlanFinal, "user")}},
	},
	want: []*genai.Content{
		genai.NewContentFromText("Plan how to add feature Q", "user"),
		NewContentFromFunctionCall(fcAsyncStatus, "model"),
		NewContentFromFunctionResponse(frAsyncStatus, "user"),
		NewContentFromFunctionCall(fcAsyncPlan, "model"),
		NewContentFromFunctionResponse(frAsyncPlanFinal, "user"),
	},
},

How often has this issue occurred?:

  • Always (100%) — deterministic whenever a function response arrives as the latest event with an unrelated tool exchange between it and its call.

Additional Context:

Relationship to existing issues:

adk-python parity: adk-python cannot reach this state — its _rearrange_events_for_latest_function_response truncates history (result_events = events[:function_call_event_idx + 1] plus the merged response), so its tail is always the merged latest response, at the cost of dropping the unrelated exchange (the very behavior #767 fixed in Go). On the observable property ("what is the final content when a long-running tool completes late?") Go currently disagrees with adk-python; the proposed fix re-aligns the observable tail while keeping Go's superior no-drop behavior.

Proposed fix (PR to follow): keep pass 2's adjacency invariant (every call event immediately followed by its consolidated response) and add one rule — the call/response pair answered by the final event is emitted last. One-function change in rearrangeEventsForFunctionResponsesInHistory; provably a no-op whenever the last event is not a function response; one existing test expectation updates (the #767 case, with the reasoning above), four regression cases added. Live-verified end-to-end with gemini-3.1-pro against the patched module.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions