v3.11.11: stricter function_call/output pairing + Gemini sanitizer rewrite (PR #12)

This commit is contained in:
Roman | RyzenAdvanced
2026-05-26 22:06:02 +04:00
Unverified
parent aa7b9e8280
commit 01080fd30f
6 changed files with 77 additions and 45 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## v3.11.11 (2026-05-26)
**Antigravity Fix: Stricter function_call/output Pairing + Gemini Sanitizer Rewrite (PR #12)**
### Bug Fixes
- **Stricter function_call/output pairing**: Only includes pairs where BOTH call and output exist — no orphan calls sent to Gemini
- **Gemini sanitizer rewritten**: Tool messages (`functionCall`/`functionResponse`) are always preserved as-is, never merged or skipped
- **Text merging more conservative**: Checks last message for tool content before merging consecutive text messages
- **Final trimming safe**: Only removes plain `message` items, never `function_call_output` (which would break tool pairs)
- **Merge PR #12**: Fix by qwen-chat coder
## v3.11.10 (2026-05-26)
## v3.11.10 (2026-05-26) ## v3.11.10 (2026-05-26)
**Antigravity Fix: Interleave function_call/output Pairs, Gemini Turn Trimming (PR #11)** **Antigravity Fix: Interleave function_call/output Pairs, Gemini Turn Trimming (PR #11)**

Binary file not shown.

Binary file not shown.

View File

@@ -27,6 +27,10 @@ model_catalog_json = ""
""" """
CHANGELOG = [ CHANGELOG = [
("3.11.11", "2026-05-26", [
"Fix Antigravity: stricter function_call/output pairing (PR #12)",
"Gemini sanitizer rewritten — tool messages always preserved",
]),
("3.11.10", "2026-05-26", [ ("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs (PR #11)", "Fix Antigravity: interleave function_call/output pairs (PR #11)",
"Gemini sanitizer: trim non-user turns for Google API compliance", "Gemini sanitizer: trim non-user turns for Google API compliance",

View File

@@ -83,6 +83,12 @@ model_catalog_json = ""
""" """
CHANGELOG = [ CHANGELOG = [
("3.11.11", "2026-05-26", [
"Fix Antigravity: stricter function_call/output pairing + Gemini sanitizer rewrite (PR #12)",
"Only pairs where BOTH call and output exist are included — no orphan calls",
"Gemini sanitizer: tool messages always preserved as-is, text merging more conservative",
"Final trimming only removes plain messages, never function_call_output",
]),
("3.11.10", "2026-05-26", [ ("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)", "Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)",
"Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance", "Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance",

View File

@@ -4841,6 +4841,14 @@ def _antigravity_is_simple_user(text):
return False return False
def _antigravity_normalize_context(input_data, model=""): def _antigravity_normalize_context(input_data, model=""):
"""
Normalize context for Antigravity while PRESERVING function_call -> function_call_output pairs.
Google's Gemini API requires STRICT alternation:
- functionCall (role=model) MUST be immediately followed by functionResponse (role=user)
This function compacts old history but NEVER breaks tool call/response pairs.
"""
if not isinstance(input_data, list) or len(input_data) < 2: if not isinstance(input_data, list) or len(input_data) < 2:
return input_data return input_data
is_claude_model = "claude" in model.lower() is_claude_model = "claude" in model.lower()
@@ -4889,7 +4897,7 @@ def _antigravity_normalize_context(input_data, model=""):
dev_messages = [] dev_messages = []
recent_items = [] recent_items = []
tool_outputs = [] tool_outputs = []
other_items = [] tool_calls = []
for i, item in enumerate(input_data): for i, item in enumerate(input_data):
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -4899,8 +4907,8 @@ def _antigravity_normalize_context(input_data, model=""):
dev_messages.append(item) dev_messages.append(item)
elif t == "function_call_output": elif t == "function_call_output":
tool_outputs.append((i, item)) tool_outputs.append((i, item))
elif t in ("function_call",): elif t == "function_call":
other_items.append((i, item)) tool_calls.append((i, item))
elif t == "message": elif t == "message":
recent_items.append((i, item)) recent_items.append((i, item))
@@ -4946,19 +4954,14 @@ def _antigravity_normalize_context(input_data, model=""):
deduped_tail.append((idx, msg_item)) deduped_tail.append((idx, msg_item))
recent_tail = deduped_tail if deduped_tail else recent_tail recent_tail = deduped_tail if deduped_tail else recent_tail
tool_call_ids = set() # Build call_id -> function_call mapping
for _, t_item in kept_tools: tool_call_map = {}
cid = t_item.get("call_id", t_item.get("id", "")) for _, call_item in tool_calls:
cid = call_item.get("call_id", call_item.get("id", ""))
if cid: if cid:
tool_call_ids.add(cid) tool_call_map[cid] = call_item
paired_calls = [] # Build result: maintain PAIRED sequence (function_call -> function_call_output)
for idx, item in other_items:
cid = item.get("call_id", item.get("id", ""))
if cid in tool_call_ids:
paired_calls.append((idx, item))
# Build result maintaining proper function_call -> function_call_output pairing
result = list(dev_messages) result = list(dev_messages)
compaction_summaries = [] compaction_summaries = []
@@ -4974,29 +4977,21 @@ def _antigravity_normalize_context(input_data, model=""):
summary_text = f"[Tool history summary: {n_summarized} older tool outputs omitted. {n_tool_calls} prior function calls were made for file inspection/editing.]" summary_text = f"[Tool history summary: {n_summarized} older tool outputs omitted. {n_tool_calls} prior function calls were made for file inspection/editing.]"
result.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": summary_text}]}) result.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": summary_text}]})
# CRITICAL FIX: Interleave function_calls with their corresponding function_call_outputs # CRITICAL: Add tool CALLS and their corresponding OUTPUTS in PAIRED ORDER
# to maintain the required sequence: function_call -> function_call_output -> function_call -> ... # Only include pairs where BOTH call and output are present
# Build a lookup map: call_id -> function_call_output item added_pairs = set()
tool_output_map = {}
for _, tool_item in kept_tools: for _, tool_item in kept_tools:
cid = tool_item.get("call_id", tool_item.get("id", "")) cid = tool_item.get("call_id", tool_item.get("id", ""))
if cid: if cid and cid in tool_call_map and cid not in added_pairs:
tool_output_map[cid] = tool_item # Add function_call FIRST, then function_call_output IMMEDIATELY
result.append(tool_call_map[cid])
result.append(tool_item)
added_pairs.add(cid)
# First, add all paired function_calls followed immediately by their responses # Add any orphan tool outputs (no matching call found) - these go at the end before messages
added_call_ids = set()
for _, call_item in paired_calls:
cid = call_item.get("call_id", call_item.get("id", ""))
result.append(call_item)
added_call_ids.add(cid)
# Immediately append the corresponding function_call_output if available
if cid in tool_output_map:
result.append(tool_output_map[cid])
# Add any remaining tool outputs that weren't paired (orphans)
for _, tool_item in kept_tools: for _, tool_item in kept_tools:
cid = tool_item.get("call_id", tool_item.get("id", "")) cid = tool_item.get("call_id", tool_item.get("id", ""))
if cid not in added_call_ids: if cid not in added_pairs:
result.append(tool_item) result.append(tool_item)
for cs_item in compaction_summaries: for cs_item in compaction_summaries:
@@ -5037,7 +5032,7 @@ def _antigravity_normalize_context(input_data, model=""):
while len(result) > _ANTIGRAVITY_MAX_CONTENTS and total_chars > _ANTIGRAVITY_SOFT_CHARS: while len(result) > _ANTIGRAVITY_MAX_CONTENTS and total_chars > _ANTIGRAVITY_SOFT_CHARS:
for i in range(1, len(result) - 1): for i in range(1, len(result) - 1):
if isinstance(result[i], dict) and result[i].get("type") in ("message", "function_call_output"): if isinstance(result[i], dict) and result[i].get("type") in ("message",):
removed = result.pop(i) removed = result.pop(i)
total_chars -= len(json.dumps(removed, ensure_ascii=False)) total_chars -= len(json.dumps(removed, ensure_ascii=False))
break break
@@ -5573,33 +5568,47 @@ class Handler(http.server.BaseHTTPRequestHandler):
resp_part["functionResponse"]["id"] = call_id resp_part["functionResponse"]["id"] = call_id
contents.append({"role": "user", "parts": [resp_part]}) contents.append({"role": "user", "parts": [resp_part]})
# CRITICAL FIX: Sanitize contents while PRESERVING functionCall -> functionResponse alternation.
# Google's Gemini API REQUIRES: functionCall (role=model) must be immediately followed by functionResponse (role=user).
# We NEVER merge, skip, or reorder tool-related messages.
if OAUTH_PROVIDER.startswith("google") and "claude" not in model.lower(): if OAUTH_PROVIDER.startswith("google") and "claude" not in model.lower():
sanitized = [] sanitized = []
last_user_text = None last_user_text = None
last_role = None
for content in contents: for content in contents:
role = content.get("role") role = content.get("role")
parts = [p for p in content.get("parts", []) if isinstance(p, dict)] parts = [p for p in content.get("parts", []) if isinstance(p, dict)]
if not parts: if not parts:
continue continue
# Check if this content has functionCall or functionResponse - these must be preserved # Check if this content has functionCall or functionResponse - these MUST be preserved as-is
has_function_call = any("functionCall" in p for p in parts) has_function_call = any("functionCall" in p for p in parts)
has_function_response = any("functionResponse" in p for p in parts) has_function_response = any("functionResponse" in p for p in parts)
text_key = "\n".join([p.get("text", "") for p in parts if "text" in p]).strip() text_key = "\n".join([p.get("text", "") for p in parts if "text" in p]).strip()
# Skip duplicate user text messages, but NEVER skip function responses
if role == "user" and text_key and text_key == last_user_text and not has_function_response: # Tool calls/responses are NEVER merged or skipped - they must maintain strict order
if has_function_call or has_function_response:
sanitized.append({"role": role, "parts": parts})
continue continue
# CRITICAL FIX: Function calls (model role) and function responses (user role) MUST NOT be merged
# Google's API requires strict alternation: functionCall (model) -> functionResponse (user) # For plain text messages only: skip duplicate consecutive user text
# Never merge across role boundaries when function calls/responses are involved if role == "user" and text_key and text_key == last_user_text:
if role == last_role and role in ("user", "model") and sanitized and not has_function_call and not has_function_response: continue
# Only merge same-role consecutive text messages without tool content
# Merge consecutive same-role TEXT-ONLY messages (no tool content)
if role == last_role and role in ("user", "model") and sanitized:
last_parts = sanitized[-1].get("parts", [])
# Only merge if the last message is also text-only (no functionCall/functionResponse)
last_has_tool = any("functionCall" in p or "functionResponse" in p for p in last_parts)
if not last_has_tool:
sanitized[-1].setdefault("parts", []).extend(parts) sanitized[-1].setdefault("parts", []).extend(parts)
else: if role == "user" and text_key:
last_user_text = text_key
continue
sanitized.append({"role": role, "parts": parts}) sanitized.append({"role": role, "parts": parts})
if role == "user" and text_key: if role == "user" and text_key:
last_user_text = text_key last_user_text = text_key
last_role = role last_role = role
# Trim leading non-user messages (Google expects conversation to start with user) # Trim leading non-user messages (Google expects conversation to start with user)
while sanitized and sanitized[0].get("role") != "user": while sanitized and sanitized[0].get("role") != "user":
sanitized.pop(0) sanitized.pop(0)