diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6501e..850edd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 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) **Antigravity Fix: Interleave function_call/output Pairs, Gemini Turn Trimming (PR #11)** diff --git a/codex-launcher_3.11.10_all.deb b/codex-launcher_3.11.10_all.deb deleted file mode 100644 index f142b5e..0000000 Binary files a/codex-launcher_3.11.10_all.deb and /dev/null differ diff --git a/codex-launcher_3.11.11_all.deb b/codex-launcher_3.11.11_all.deb new file mode 100644 index 0000000..61331b2 Binary files /dev/null and b/codex-launcher_3.11.11_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 0c046a5..4dd1522 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -27,6 +27,10 @@ model_catalog_json = "" """ 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", [ "Fix Antigravity: interleave function_call/output pairs (PR #11)", "Gemini sanitizer: trim non-user turns for Google API compliance", diff --git a/src/codex_launcher_lib.py b/src/codex_launcher_lib.py index 6b2b1be..7aa1ec9 100644 --- a/src/codex_launcher_lib.py +++ b/src/codex_launcher_lib.py @@ -83,6 +83,12 @@ model_catalog_json = "" """ 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", [ "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", diff --git a/src/translate-proxy.py b/src/translate-proxy.py index d2396ef..909d72e 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -4841,6 +4841,14 @@ def _antigravity_is_simple_user(text): return False 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: return input_data is_claude_model = "claude" in model.lower() @@ -4889,7 +4897,7 @@ def _antigravity_normalize_context(input_data, model=""): dev_messages = [] recent_items = [] tool_outputs = [] - other_items = [] + tool_calls = [] for i, item in enumerate(input_data): if not isinstance(item, dict): @@ -4899,8 +4907,8 @@ def _antigravity_normalize_context(input_data, model=""): dev_messages.append(item) elif t == "function_call_output": tool_outputs.append((i, item)) - elif t in ("function_call",): - other_items.append((i, item)) + elif t == "function_call": + tool_calls.append((i, item)) elif t == "message": recent_items.append((i, item)) @@ -4946,19 +4954,14 @@ def _antigravity_normalize_context(input_data, model=""): deduped_tail.append((idx, msg_item)) recent_tail = deduped_tail if deduped_tail else recent_tail - tool_call_ids = set() - for _, t_item in kept_tools: - cid = t_item.get("call_id", t_item.get("id", "")) + # Build call_id -> function_call mapping + tool_call_map = {} + for _, call_item in tool_calls: + cid = call_item.get("call_id", call_item.get("id", "")) if cid: - tool_call_ids.add(cid) + tool_call_map[cid] = call_item - paired_calls = [] - 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 + # Build result: maintain PAIRED sequence (function_call -> function_call_output) result = list(dev_messages) 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.]" result.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": summary_text}]}) - # CRITICAL FIX: Interleave function_calls with their corresponding function_call_outputs - # to maintain the required sequence: function_call -> function_call_output -> function_call -> ... - # Build a lookup map: call_id -> function_call_output item - tool_output_map = {} + # CRITICAL: Add tool CALLS and their corresponding OUTPUTS in PAIRED ORDER + # Only include pairs where BOTH call and output are present + added_pairs = set() for _, tool_item in kept_tools: cid = tool_item.get("call_id", tool_item.get("id", "")) - if cid: - tool_output_map[cid] = tool_item + if cid and cid in tool_call_map and cid not in added_pairs: + # 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 - 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) + # Add any orphan tool outputs (no matching call found) - these go at the end before messages for _, tool_item in kept_tools: 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) 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: 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) total_chars -= len(json.dumps(removed, ensure_ascii=False)) break @@ -5573,33 +5568,47 @@ class Handler(http.server.BaseHTTPRequestHandler): resp_part["functionResponse"]["id"] = call_id 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(): sanitized = [] last_user_text = None - last_role = None for content in contents: role = content.get("role") parts = [p for p in content.get("parts", []) if isinstance(p, dict)] if not parts: 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_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() - # 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: - 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) - # Never merge across role boundaries when function calls/responses are involved - if role == last_role and role in ("user", "model") and sanitized and not has_function_call and not has_function_response: - # Only merge same-role consecutive text messages without tool content - sanitized[-1].setdefault("parts", []).extend(parts) - else: + + # 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 + + # For plain text messages only: skip duplicate consecutive user text + if role == "user" and text_key and text_key == last_user_text: + continue + + # 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) + if role == "user" and text_key: + last_user_text = text_key + continue + + sanitized.append({"role": role, "parts": parts}) if role == "user" and text_key: last_user_text = text_key last_role = role + # Trim leading non-user messages (Google expects conversation to start with user) while sanitized and sanitized[0].get("role") != "user": sanitized.pop(0)