Skip to content

fix(antigravity): reorder model parts to prevent tool_use↔tool_result pairing breakage#2409

Merged
luispater merged 1 commit intorouter-for-me:devfrom
sususu98:fix/tool-use-pairing-break
Mar 30, 2026
Merged

fix(antigravity): reorder model parts to prevent tool_use↔tool_result pairing breakage#2409
luispater merged 1 commit intorouter-for-me:devfrom
sususu98:fix/tool-use-pairing-break

Conversation

@sususu98
Copy link
Copy Markdown
Collaborator

Summary

Root Cause

When a Claude assistant message contains [text, tool_use, text], the Antigravity translator converts it to Gemini format [text_part, functionCall, text_part]. The Antigravity API internally splits model messages at functionCall boundaries, creating an extra assistant turn:

Before split:  model: [text, functionCall, text]

After split:
  model: [text]              ← first text
  model: [functionCall]      ← tool_use
  model: [text]              ← extra assistant turn!
  user:  [tool_result]       ← no longer adjacent to tool_use → 400

Claude rejects because tool_result must immediately follow the tool_use message.

Why this structure appears

The response translator (antigravity_claude_response.go) faithfully preserves part order from the backend. When the backend returns [text, functionCall, text], the response translator converts it to [text, tool_use, text] in Claude format. The client stores this as conversation history and replays it in the next request — triggering the bug.

Fix

Reorder model parts into 3 groups before sending to Antigravity:

  1. Thinking parts (first — existing Antigravity API requirement)
  2. Regular parts — text, inlineData, etc. (middle)
  3. FunctionCall parts (last — new)

This ensures that even when Antigravity splits at functionCall boundaries, no extra assistant turn appears between tool_use and the following tool_result.

Before fix:  [text, functionCall, text]    → split → extra turn → 400
After fix:   [text, text, functionCall]    → split → no extra turn → ✅

Tests Added

Test Scenario
ReorderTextAfterFunctionCall [text, tool_use, text][text, text, functionCall]
ReorderParallelFunctionCalls [text, fc1, text, fc2][text, text, fc1, fc2] (preserves fc order)
ReorderThinkingAndTextBeforeFunctionCall [text, thinking, tool_use, text][thinking, text, text, functionCall]

Verification

… pairing breakage

When a Claude assistant message contains [text, tool_use, text], the
Antigravity API internally splits the model message at functionCall
boundaries, creating an extra assistant turn between tool_use and the
following tool_result. Claude then rejects with:

  tool_use ids were found without tool_result blocks immediately after

Fix: extend the existing 2-way part reordering (thinking-first) to a
3-way partition: thinking → regular → functionCall. This ensures
functionCall parts are always last, so Antigravity's split cannot
insert an extra assistant turn before the user's tool_result.

Fixes router-for-me#989
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request ensures that message parts for the 'model' role are ordered with thinking blocks first, regular content second, and function calls last to prevent API message splitting that breaks tool usage. New tests verify this logic across several scenarios. Feedback was provided to optimize the reordering logic by skipping unnecessary work when parts are already in the correct order and pre-allocating the output slice.

Comment on lines +349 to +371
var thinkingParts []gjson.Result
var regularParts []gjson.Result
var functionCallParts []gjson.Result
for _, part := range parts {
if part.Get("thought").Bool() {
thinkingParts = append(thinkingParts, part)
} else if part.Get("functionCall").Exists() {
functionCallParts = append(functionCallParts, part)
} else {
regularParts = append(regularParts, part)
}
clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts)
}
var newParts []interface{}
for _, p := range thinkingParts {
newParts = append(newParts, p.Value())
}
for _, p := range regularParts {
newParts = append(newParts, p.Value())
}
for _, p := range functionCallParts {
newParts = append(newParts, p.Value())
}
clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts)
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.

medium

While the current implementation is correct, it always re-partitions and re-serializes the parts array if it contains more than one element. This can be optimized by first checking if a reorder is necessary, avoiding unnecessary work for already-sorted parts. Additionally, pre-allocating the newParts slice with make improves performance by preventing reallocations during appends.

							// Check if reordering is needed to avoid unnecessary work.
							// The correct order is: thinking -> regular -> functionCall.
							var needsReorder bool
							var seenRegular, seenFunctionCall bool
							for _, part := range parts {
								isThinking := part.Get("thought").Bool()
								isFunctionCall := part.Get("functionCall").Exists()

								if isThinking {
									if seenRegular || seenFunctionCall {
										needsReorder = true
										break
									}
								} else if isFunctionCall {
									seenFunctionCall = true
								} else { // regular part
									if seenFunctionCall {
										needsReorder = true
										break
									}
									seenRegular = true
								}
							}

							if needsReorder {
								var thinkingParts, regularParts, functionCallParts []gjson.Result
								for _, part := range parts {
									if part.Get("thought").Bool() {
										thinkingParts = append(thinkingParts, part)
									} else if part.Get("functionCall").Exists() {
										functionCallParts = append(functionCallParts, part)
									} else {
										regularParts = append(regularParts, part)
									}
								}

								newParts := make([]interface{}, 0, len(parts))
								for _, p := range thinkingParts {
									newParts = append(newParts, p.Value())
								}
								for _, p := range regularParts {
									newParts = append(newParts, p.Value())
								}
								for _, p := range functionCallParts {
									newParts = append(newParts, p.Value())
								}
								clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts)
							}

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25feceb783

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +361 to +363
var newParts []interface{}
for _, p := range thinkingParts {
newParts = append(newParts, p.Value())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve raw JSON when reordering model parts

The new len(parts) > 1 path now runs for any multi-part assistant message and rebuilds each part via p.Value() before sjson.SetBytes(...). gjson.Result.Value() converts JSON numbers to Go float64, so tool arguments containing 64-bit integers (IDs, timestamps, snowflakes, etc.) can be rounded during replay (for example in [text, tool_use] messages that previously skipped this rewrite). This changes tool-call payloads and can cause downstream tool mismatches; use raw JSON (p.Raw/SetRawBytes) or only rewrite when an actual reorder is required.

Useful? React with 👍 / 👎.

@luispater luispater merged commit 486cd4c into router-for-me:dev Mar 30, 2026
1 of 2 checks passed
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.

2 participants