Mirrowel commited on
Commit
a9943e6
·
1 Parent(s): 50ee93b

feat(antigravity): ✨Gemini auto-unfuck: implement auto-recovery for malformed function calls

Browse files

This commit introduces a comprehensive handling mechanism for `MALFORMED_FUNCTION_CALL` errors occasionally produced by Gemini models, allowing the system to recover from invalid JSON tool arguments.

- **Auto-Fix Logic**: Implements a heuristic JSON repair engine (`_analyze_json_error`) to locally correct common syntax errors like unquoted keys, single quotes, and trailing commas.
- **Retry Strategy**: Adds a retry loop that injects corrective error messages and schema hints back into the conversation if local auto-fix fails.
- **System Prompt**: Updates model instructions to explicitly emphasize strict JSON syntax requirements (double quotes).
- **Logging**: Adds dedicated logging methods to track malformed requests, retry payloads, and auto-fix results for debugging.
- **Configuration**: Introduces `MALFORMED_CALL_MAX_RETRIES` and `MALFORMED_CALL_RETRY_DELAY` settings.

src/rotator_library/providers/antigravity_provider.py CHANGED
@@ -55,6 +55,25 @@ if TYPE_CHECKING:
55
  from ..usage_manager import UsageManager
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  # =============================================================================
59
  # CONFIGURATION CONSTANTS
60
  # =============================================================================
@@ -115,6 +134,12 @@ DEFAULT_MAX_OUTPUT_TOKENS = 64000
115
  EMPTY_RESPONSE_MAX_ATTEMPTS = max(1, _env_int("ANTIGRAVITY_EMPTY_RESPONSE_ATTEMPTS", 6))
116
  EMPTY_RESPONSE_RETRY_DELAY = _env_int("ANTIGRAVITY_EMPTY_RESPONSE_RETRY_DELAY", 3)
117
 
 
 
 
 
 
 
118
  # Model alias mappings (internal ↔ public)
119
  MODEL_ALIAS_MAP = {
120
  "rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025",
@@ -215,6 +240,10 @@ VIOLATION OF THESE RULES WILL CAUSE IMMEDIATE SYSTEM FAILURE.
215
  d. For arrays, verify you're providing the correct item structure
216
  e. Do NOT add parameters that don't exist in the schema
217
 
 
 
 
 
218
  ## COMMON FAILURE PATTERNS TO AVOID
219
 
220
  - Using 'path' when schema says 'filePath' (or vice versa)
@@ -223,6 +252,9 @@ VIOLATION OF THESE RULES WILL CAUSE IMMEDIATE SYSTEM FAILURE.
223
  - Omitting required nested fields in array items
224
  - Adding 'additionalProperties' that the schema doesn't define
225
  - Guessing parameter names from similar tools you know from training
 
 
 
226
 
227
  ## REMEMBER
228
  Your training data about function calling is OUTDATED for this environment.
@@ -659,10 +691,34 @@ class AntigravityFileLogger:
659
  "error.log", f"[{datetime.utcnow().isoformat()}] {error_message}"
660
  )
661
 
 
 
 
 
 
 
 
 
 
 
662
  def log_final_response(self, response: Dict[str, Any]) -> None:
663
  """Log the final response."""
664
  self._write_json("final_response.json", response)
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  def _write_json(self, filename: str, data: Dict[str, Any]) -> None:
667
  if not self.enabled or not self.log_dir:
668
  return
@@ -2838,6 +2894,387 @@ class AntigravityProvider(
2838
  return GEMINI3_TOOL_RENAMES_REVERSE.get(stripped, stripped)
2839
  return name
2840
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2841
  def _translate_tool_choice(
2842
  self, tool_choice: Union[str, Dict[str, Any]], model: str = ""
2843
  ) -> Optional[Dict[str, Any]]:
@@ -3665,6 +4102,10 @@ class AntigravityProvider(
3665
  )
3666
  file_logger.log_request(payload)
3667
 
 
 
 
 
3668
  # Make API call
3669
  base_url = self._get_base_url()
3670
  endpoint = ":streamGenerateContent" if stream else ":generateContent"
@@ -3682,6 +4123,11 @@ class AntigravityProvider(
3682
  **ANTIGRAVITY_HEADERS,
3683
  }
3684
 
 
 
 
 
 
3685
  # URL fallback loop - handles HTTP errors (except 429) and network errors
3686
  # by switching to fallback URLs. Empty response retry is handled separately
3687
  # inside _streaming_with_retry (streaming) or the inner loop (non-streaming).
@@ -3696,9 +4142,16 @@ class AntigravityProvider(
3696
  payload,
3697
  model,
3698
  file_logger,
 
 
 
 
 
 
 
3699
  )
3700
  else:
3701
- # Non-streaming: empty response and bare 429 retry loop
3702
  empty_error_msg = (
3703
  "The model returned an empty response after multiple attempts. "
3704
  "This may indicate a temporary service issue. Please try again."
@@ -3746,6 +4199,101 @@ class AntigravityProvider(
3746
 
3747
  return result
3748
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3749
  except httpx.HTTPStatusError as e:
3750
  if e.response.status_code == 429:
3751
  # Check if this is a bare 429 (no retry info) vs real quota exhaustion
@@ -3772,16 +4320,19 @@ class AntigravityProvider(
3772
  )
3773
  # Re-raise all HTTP errors (429 with retry info, or other errors)
3774
  raise
3775
-
3776
- # Should not reach here, but just in case
3777
- lib_logger.error(
3778
- f"[Antigravity] Unexpected exit from retry loop for {model}"
3779
- )
3780
- raise EmptyResponseError(
3781
- provider="antigravity",
3782
- model=model,
3783
- message=empty_error_msg,
3784
- )
 
 
 
3785
 
3786
  except httpx.HTTPStatusError as e:
3787
  # 429 = Rate limit/quota exhausted - tied to credential, not URL
@@ -3863,6 +4414,11 @@ class AntigravityProvider(
3863
 
3864
  gemini_response = self._unwrap_response(data)
3865
 
 
 
 
 
 
3866
  # Build tool schema map for schema-aware JSON parsing
3867
  tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
3868
  openai_response = self._gemini_to_openai_non_streaming(
@@ -3879,8 +4435,14 @@ class AntigravityProvider(
3879
  payload: Dict[str, Any],
3880
  model: str,
3881
  file_logger: Optional[AntigravityFileLogger] = None,
 
3882
  ) -> AsyncGenerator[litellm.ModelResponse, None]:
3883
- """Handle streaming completion."""
 
 
 
 
 
3884
  # Build tool schema map for schema-aware JSON parsing
3885
  tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
3886
 
@@ -3895,6 +4457,8 @@ class AntigravityProvider(
3895
  "last_usage": None, # Track last received usage for final chunk
3896
  "yielded_any": False, # Track if we yielded any real chunks
3897
  "tool_schemas": tool_schemas, # For schema-aware JSON string parsing
 
 
3898
  }
3899
 
3900
  async with client.stream(
@@ -3919,7 +4483,12 @@ class AntigravityProvider(
3919
 
3920
  async for line in response.aiter_lines():
3921
  if file_logger:
3922
- file_logger.log_response_chunk(line)
 
 
 
 
 
3923
 
3924
  if line.startswith("data: "):
3925
  data_str = line[6:]
@@ -3929,6 +4498,18 @@ class AntigravityProvider(
3929
  try:
3930
  chunk = json.loads(data_str)
3931
  gemini_chunk = self._unwrap_response(chunk)
 
 
 
 
 
 
 
 
 
 
 
 
3932
  openai_chunk = self._gemini_to_openai_chunk(
3933
  gemini_chunk, model, accumulator
3934
  )
@@ -3940,6 +4521,13 @@ class AntigravityProvider(
3940
  file_logger.log_error(f"Parse error: {data_str[:100]}")
3941
  continue
3942
 
 
 
 
 
 
 
 
3943
  # Only emit synthetic final chunk if we actually received real data
3944
  # If no data was received, the caller will detect zero chunks and retry
3945
  if accumulator.get("yielded_any"):
@@ -3978,13 +4566,24 @@ class AntigravityProvider(
3978
  payload: Dict[str, Any],
3979
  model: str,
3980
  file_logger: Optional[AntigravityFileLogger] = None,
 
 
 
 
 
 
 
3981
  ) -> AsyncGenerator[litellm.ModelResponse, None]:
3982
  """
3983
- Wrapper around _handle_streaming that retries on empty responses and bare 429s.
 
3984
 
3985
  If the stream yields zero chunks (Antigravity returned nothing) or encounters
3986
  a bare 429 (no retry info), retry up to EMPTY_RESPONSE_MAX_ATTEMPTS times
3987
  before giving up.
 
 
 
3988
  """
3989
  empty_error_msg = (
3990
  "The model returned an empty response after multiple attempts. "
@@ -3995,17 +4594,25 @@ class AntigravityProvider(
3995
  "This may indicate a temporary service issue. Please try again."
3996
  )
3997
 
 
 
 
 
 
3998
  for attempt in range(EMPTY_RESPONSE_MAX_ATTEMPTS):
3999
  chunk_count = 0
4000
 
4001
  try:
 
 
4002
  async for chunk in self._handle_streaming(
4003
  client,
4004
  url,
4005
  headers,
4006
- payload,
4007
  model,
4008
  file_logger,
 
4009
  ):
4010
  chunk_count += 1
4011
  yield chunk # Stream immediately - true streaming preserved
@@ -4030,6 +4637,105 @@ class AntigravityProvider(
4030
  message=empty_error_msg,
4031
  )
4032
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4033
  except httpx.HTTPStatusError as e:
4034
  if e.response.status_code == 429:
4035
  # Check if this is a bare 429 (no retry info) vs real quota exhaustion
 
55
  from ..usage_manager import UsageManager
56
 
57
 
58
+ # =============================================================================
59
+ # INTERNAL EXCEPTIONS
60
+ # =============================================================================
61
+
62
+
63
+ class _MalformedFunctionCallDetected(Exception):
64
+ """
65
+ Internal exception raised when MALFORMED_FUNCTION_CALL is detected.
66
+
67
+ Signals the retry logic to inject corrective messages and retry.
68
+ Not intended to be raised to callers.
69
+ """
70
+
71
+ def __init__(self, finish_message: str, raw_response: Dict[str, Any]):
72
+ self.finish_message = finish_message
73
+ self.raw_response = raw_response
74
+ super().__init__(finish_message)
75
+
76
+
77
  # =============================================================================
78
  # CONFIGURATION CONSTANTS
79
  # =============================================================================
 
134
  EMPTY_RESPONSE_MAX_ATTEMPTS = max(1, _env_int("ANTIGRAVITY_EMPTY_RESPONSE_ATTEMPTS", 6))
135
  EMPTY_RESPONSE_RETRY_DELAY = _env_int("ANTIGRAVITY_EMPTY_RESPONSE_RETRY_DELAY", 3)
136
 
137
+ # Malformed function call retry configuration
138
+ # When Gemini 3 returns MALFORMED_FUNCTION_CALL (invalid JSON syntax in tool args),
139
+ # inject corrective messages and retry up to this many times
140
+ MALFORMED_CALL_MAX_RETRIES = max(1, _env_int("ANTIGRAVITY_MALFORMED_CALL_RETRIES", 2))
141
+ MALFORMED_CALL_RETRY_DELAY = _env_int("ANTIGRAVITY_MALFORMED_CALL_DELAY", 1)
142
+
143
  # Model alias mappings (internal ↔ public)
144
  MODEL_ALIAS_MAP = {
145
  "rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025",
 
240
  d. For arrays, verify you're providing the correct item structure
241
  e. Do NOT add parameters that don't exist in the schema
242
 
243
+ 7. **JSON SYNTAX**: Function call arguments must be valid JSON.
244
+ - All keys MUST be double-quoted: {"key":"value"} not {key:"value"}
245
+ - Use double quotes for strings, not single quotes
246
+
247
  ## COMMON FAILURE PATTERNS TO AVOID
248
 
249
  - Using 'path' when schema says 'filePath' (or vice versa)
 
252
  - Omitting required nested fields in array items
253
  - Adding 'additionalProperties' that the schema doesn't define
254
  - Guessing parameter names from similar tools you know from training
255
+ - Using unquoted keys: {key:"value"} instead of {"key":"value"}
256
+ - Writing JSON as text in your response instead of making an actual function call
257
+ - Using single quotes instead of double quotes for strings
258
 
259
  ## REMEMBER
260
  Your training data about function calling is OUTDATED for this environment.
 
691
  "error.log", f"[{datetime.utcnow().isoformat()}] {error_message}"
692
  )
693
 
694
+ def log_malformed_retry_request(
695
+ self, retry_num: int, payload: Dict[str, Any]
696
+ ) -> None:
697
+ """Log a malformed call retry request payload in the same folder."""
698
+ self._write_json(f"malformed_retry_{retry_num}_request.json", payload)
699
+
700
+ def log_malformed_retry_response(self, retry_num: int, chunk: str) -> None:
701
+ """Append a chunk to the malformed retry response log."""
702
+ self._append_text(f"malformed_retry_{retry_num}_response.log", chunk)
703
+
704
  def log_final_response(self, response: Dict[str, Any]) -> None:
705
  """Log the final response."""
706
  self._write_json("final_response.json", response)
707
 
708
+ def log_malformed_autofix(
709
+ self, tool_name: str, raw_args: str, fixed_json: str
710
+ ) -> None:
711
+ """Log details of an auto-fixed malformed function call."""
712
+ self._write_json(
713
+ "malformed_autofix.json",
714
+ {
715
+ "tool_name": tool_name,
716
+ "raw_args": raw_args,
717
+ "fixed_json": fixed_json,
718
+ "timestamp": datetime.utcnow().isoformat(),
719
+ },
720
+ )
721
+
722
  def _write_json(self, filename: str, data: Dict[str, Any]) -> None:
723
  if not self.enabled or not self.log_dir:
724
  return
 
2894
  return GEMINI3_TOOL_RENAMES_REVERSE.get(stripped, stripped)
2895
  return name
2896
 
2897
+ # =========================================================================
2898
+ # MALFORMED FUNCTION CALL HANDLING
2899
+ # =========================================================================
2900
+
2901
+ def _check_for_malformed_call(self, response: Dict[str, Any]) -> Optional[str]:
2902
+ """
2903
+ Check if response contains MALFORMED_FUNCTION_CALL.
2904
+
2905
+ Returns finishMessage if malformed, None otherwise.
2906
+ """
2907
+ candidates = response.get("candidates", [])
2908
+ if not candidates:
2909
+ return None
2910
+
2911
+ candidate = candidates[0]
2912
+ if candidate.get("finishReason") == "MALFORMED_FUNCTION_CALL":
2913
+ return candidate.get("finishMessage", "Unknown malformed call error")
2914
+
2915
+ return None
2916
+
2917
+ def _parse_malformed_call_message(
2918
+ self, finish_message: str, model: str
2919
+ ) -> Optional[Dict[str, Any]]:
2920
+ """
2921
+ Parse MALFORMED_FUNCTION_CALL finishMessage to extract tool info.
2922
+
2923
+ Input format: "Malformed function call: call:namespace:tool_name{raw_args}"
2924
+
2925
+ Returns:
2926
+ {"tool_name": "read", "prefixed_name": "gemini3_read",
2927
+ "raw_args": "{filePath: \"...\"}"}
2928
+ or None if unparseable
2929
+ """
2930
+ import re
2931
+
2932
+ # Pattern: "Malformed function call: call:namespace:tool_name{args}"
2933
+ pattern = r"Malformed function call:\s*call:[^:]+:([^{]+)(\{.+\})$"
2934
+ match = re.match(pattern, finish_message, re.DOTALL)
2935
+
2936
+ if not match:
2937
+ lib_logger.warning(
2938
+ f"[Antigravity] Could not parse MALFORMED_FUNCTION_CALL: {finish_message[:100]}"
2939
+ )
2940
+ return None
2941
+
2942
+ prefixed_name = match.group(1).strip() # "gemini3_read"
2943
+ raw_args = match.group(2) # "{filePath: \"...\"}"
2944
+
2945
+ # Strip our prefix to get original tool name
2946
+ tool_name = self._strip_gemini3_prefix(prefixed_name)
2947
+
2948
+ return {
2949
+ "tool_name": tool_name,
2950
+ "prefixed_name": prefixed_name,
2951
+ "raw_args": raw_args,
2952
+ }
2953
+
2954
+ def _analyze_json_error(self, raw_args: str) -> Dict[str, Any]:
2955
+ """
2956
+ Analyze malformed JSON to detect specific errors and attempt to fix it.
2957
+
2958
+ Combines json.JSONDecodeError with heuristic pattern detection
2959
+ to provide actionable error information.
2960
+
2961
+ Returns:
2962
+ {
2963
+ "json_error": str or None, # Python's JSON error message
2964
+ "json_position": int or None, # Position of error
2965
+ "issues": List[str], # Human-readable issues detected
2966
+ "unquoted_keys": List[str], # Specific unquoted key names
2967
+ "fixed_json": str or None, # Corrected JSON if we could fix it
2968
+ }
2969
+ """
2970
+ import re as re_module
2971
+
2972
+ result = {
2973
+ "json_error": None,
2974
+ "json_position": None,
2975
+ "issues": [],
2976
+ "unquoted_keys": [],
2977
+ "fixed_json": None,
2978
+ }
2979
+
2980
+ # Option 1: Try json.loads to get exact error
2981
+ try:
2982
+ json.loads(raw_args)
2983
+ return result # Valid JSON, no errors
2984
+ except json.JSONDecodeError as e:
2985
+ result["json_error"] = e.msg
2986
+ result["json_position"] = e.pos
2987
+
2988
+ # Option 2: Heuristic pattern detection for specific issues
2989
+ # Detect unquoted keys: {word: or ,word:
2990
+ unquoted_key_pattern = r"[{,]\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"
2991
+ unquoted_keys = re_module.findall(unquoted_key_pattern, raw_args)
2992
+ if unquoted_keys:
2993
+ result["unquoted_keys"] = unquoted_keys
2994
+ if len(unquoted_keys) == 1:
2995
+ result["issues"].append(f"Unquoted key: '{unquoted_keys[0]}'")
2996
+ else:
2997
+ result["issues"].append(
2998
+ f"Unquoted keys: {', '.join(repr(k) for k in unquoted_keys)}"
2999
+ )
3000
+
3001
+ # Detect single quotes
3002
+ if "'" in raw_args:
3003
+ result["issues"].append("Single quotes used instead of double quotes")
3004
+
3005
+ # Detect trailing comma
3006
+ if re_module.search(r",\s*[}\]]", raw_args):
3007
+ result["issues"].append("Trailing comma before closing bracket")
3008
+
3009
+ # Option 3: Try to fix the JSON and validate
3010
+ fixed = raw_args
3011
+ # Add quotes around unquoted keys
3012
+ fixed = re_module.sub(
3013
+ r"([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:",
3014
+ r'\1"\2":',
3015
+ fixed,
3016
+ )
3017
+ # Replace single quotes with double quotes
3018
+ fixed = fixed.replace("'", '"')
3019
+ # Remove trailing commas
3020
+ fixed = re_module.sub(r",(\s*[}\]])", r"\1", fixed)
3021
+
3022
+ try:
3023
+ # Validate the fix works
3024
+ parsed = json.loads(fixed)
3025
+ # Use compact JSON format (matches what model should produce)
3026
+ result["fixed_json"] = json.dumps(parsed, separators=(",", ":"))
3027
+ except json.JSONDecodeError:
3028
+ # First fix didn't work - try more aggressive cleanup
3029
+ pass
3030
+
3031
+ # Option 4: If first attempt failed, try more aggressive fixes
3032
+ if result["fixed_json"] is None:
3033
+ try:
3034
+ # Normalize all whitespace (collapse newlines/multiple spaces)
3035
+ aggressive_fix = re_module.sub(r"\s+", " ", fixed)
3036
+ # Try parsing again
3037
+ parsed = json.loads(aggressive_fix)
3038
+ result["fixed_json"] = json.dumps(parsed, separators=(",", ":"))
3039
+ lib_logger.debug(
3040
+ "[Antigravity] Fixed malformed JSON with aggressive whitespace normalization"
3041
+ )
3042
+ except json.JSONDecodeError:
3043
+ pass
3044
+
3045
+ # Option 5: If still failing, try fixing unquoted string values
3046
+ if result["fixed_json"] is None:
3047
+ try:
3048
+ # Some models produce unquoted string values like {key: value}
3049
+ # Try to quote values that look like unquoted strings
3050
+ # Match : followed by unquoted word (not a number, bool, null, or object/array)
3051
+ aggressive_fix = re_module.sub(
3052
+ r":\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*([,}\]])",
3053
+ r': "\1"\2',
3054
+ fixed,
3055
+ )
3056
+ parsed = json.loads(aggressive_fix)
3057
+ result["fixed_json"] = json.dumps(parsed, separators=(",", ":"))
3058
+ lib_logger.debug(
3059
+ "[Antigravity] Fixed malformed JSON by quoting unquoted string values"
3060
+ )
3061
+ except json.JSONDecodeError:
3062
+ # All fixes failed, leave as None
3063
+ pass
3064
+
3065
+ return result
3066
+
3067
+ def _build_malformed_call_retry_messages(
3068
+ self,
3069
+ parsed_call: Dict[str, Any],
3070
+ tool_schema: Optional[Dict[str, Any]],
3071
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
3072
+ """
3073
+ Build synthetic Gemini-format messages for malformed call retry.
3074
+
3075
+ Returns: (assistant_message, user_message) in Gemini format
3076
+ """
3077
+ tool_name = parsed_call["tool_name"]
3078
+ raw_args = parsed_call["raw_args"]
3079
+
3080
+ # Analyze the JSON error and try to fix it
3081
+ error_info = self._analyze_json_error(raw_args)
3082
+
3083
+ # Assistant message: Show what it tried to do
3084
+ assistant_msg = {
3085
+ "role": "model",
3086
+ "parts": [{"text": f"I'll call the '{tool_name}' function."}],
3087
+ }
3088
+
3089
+ # Build a concise error message
3090
+ if error_info["fixed_json"]:
3091
+ # We successfully fixed the JSON - show the corrected version
3092
+ error_text = f"""[FUNCTION CALL ERROR - INVALID JSON]
3093
+
3094
+ Your call to '{tool_name}' failed. All JSON keys must be double-quoted.
3095
+
3096
+ INVALID: {raw_args}
3097
+
3098
+ CORRECTED: {error_info["fixed_json"]}
3099
+
3100
+ Retry the function call now using the corrected JSON above. Output ONLY the tool call, no text."""
3101
+ else:
3102
+ # Couldn't auto-fix - give hints
3103
+ error_text = f"""[FUNCTION CALL ERROR - INVALID JSON]
3104
+
3105
+ Your call to '{tool_name}' failed due to malformed JSON.
3106
+
3107
+ You provided: {raw_args}
3108
+
3109
+ Fix: All JSON keys must be double-quoted. Example: {{"key":"value"}} not {{key:"value"}}
3110
+
3111
+ Analyze what you did wrong, correct it, and retry the function call. Output ONLY the tool call, no text."""
3112
+
3113
+ # Add schema if available (strip $schema reference)
3114
+ if tool_schema:
3115
+ clean_schema = {k: v for k, v in tool_schema.items() if k != "$schema"}
3116
+ schema_str = json.dumps(clean_schema, separators=(",", ":"))
3117
+ error_text += f"\n\nSchema: {schema_str}"
3118
+
3119
+ user_msg = {"role": "user", "parts": [{"text": error_text}]}
3120
+
3121
+ return assistant_msg, user_msg
3122
+
3123
+ def _build_malformed_fallback_response(
3124
+ self, model: str, error_details: str
3125
+ ) -> litellm.ModelResponse:
3126
+ """
3127
+ Build error response when malformed call retries are exhausted.
3128
+
3129
+ Uses finish_reason=None to indicate the response didn't complete normally,
3130
+ allowing clients to detect the incomplete state and potentially retry.
3131
+ """
3132
+ return litellm.ModelResponse(
3133
+ **{
3134
+ "id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
3135
+ "object": "chat.completion",
3136
+ "created": int(time.time()),
3137
+ "model": model,
3138
+ "choices": [
3139
+ {
3140
+ "index": 0,
3141
+ "message": {
3142
+ "role": "assistant",
3143
+ "content": (
3144
+ "[TOOL CALL ERROR] I attempted to call a function but "
3145
+ "repeatedly produced malformed syntax. This may be a model issue.\n\n"
3146
+ f"Last error: {error_details}\n\n"
3147
+ "Please try rephrasing your request or try a different approach."
3148
+ ),
3149
+ },
3150
+ "finish_reason": None,
3151
+ }
3152
+ ],
3153
+ }
3154
+ )
3155
+
3156
+ def _build_fixed_tool_call_response(
3157
+ self,
3158
+ model: str,
3159
+ parsed_call: Dict[str, Any],
3160
+ error_info: Dict[str, Any],
3161
+ ) -> Optional[litellm.ModelResponse]:
3162
+ """
3163
+ Build a synthetic valid tool call response from auto-fixed malformed JSON.
3164
+
3165
+ When Gemini 3 produces malformed JSON (e.g., unquoted keys), this method
3166
+ takes the auto-corrected JSON from _analyze_json_error() and builds a
3167
+ proper OpenAI-format tool call response.
3168
+
3169
+ Returns None if the JSON couldn't be fixed.
3170
+ """
3171
+ fixed_json = error_info.get("fixed_json")
3172
+ if not fixed_json:
3173
+ return None
3174
+
3175
+ # Validate the fixed JSON is actually valid
3176
+ try:
3177
+ json.loads(fixed_json)
3178
+ except json.JSONDecodeError:
3179
+ return None
3180
+
3181
+ tool_name = parsed_call["tool_name"]
3182
+ tool_id = f"call_{uuid.uuid4().hex[:24]}"
3183
+
3184
+ return litellm.ModelResponse(
3185
+ **{
3186
+ "id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
3187
+ "object": "chat.completion",
3188
+ "created": int(time.time()),
3189
+ "model": model,
3190
+ "choices": [
3191
+ {
3192
+ "index": 0,
3193
+ "message": {
3194
+ "role": "assistant",
3195
+ "content": None,
3196
+ "tool_calls": [
3197
+ {
3198
+ "id": tool_id,
3199
+ "type": "function",
3200
+ "function": {
3201
+ "name": tool_name,
3202
+ "arguments": fixed_json,
3203
+ },
3204
+ }
3205
+ ],
3206
+ },
3207
+ "finish_reason": "tool_calls",
3208
+ }
3209
+ ],
3210
+ }
3211
+ )
3212
+
3213
+ def _build_fixed_tool_call_chunk(
3214
+ self,
3215
+ model: str,
3216
+ parsed_call: Dict[str, Any],
3217
+ error_info: Dict[str, Any],
3218
+ response_id: Optional[str] = None,
3219
+ ) -> Optional[litellm.ModelResponse]:
3220
+ """
3221
+ Build a streaming chunk with the auto-fixed tool call.
3222
+
3223
+ Similar to _build_fixed_tool_call_response but uses streaming format:
3224
+ - object: "chat.completion.chunk" instead of "chat.completion"
3225
+ - delta: {...} instead of message: {...}
3226
+ - tool_calls items include "index" field
3227
+
3228
+ Args:
3229
+ response_id: Optional original response ID to maintain stream continuity
3230
+
3231
+ Returns None if the JSON couldn't be fixed.
3232
+ """
3233
+ fixed_json = error_info.get("fixed_json")
3234
+ if not fixed_json:
3235
+ return None
3236
+
3237
+ # Validate the fixed JSON is actually valid
3238
+ try:
3239
+ json.loads(fixed_json)
3240
+ except json.JSONDecodeError:
3241
+ return None
3242
+
3243
+ tool_name = parsed_call["tool_name"]
3244
+ tool_id = f"call_{uuid.uuid4().hex[:24]}"
3245
+ # Use original response ID if provided, otherwise generate new one
3246
+ chunk_id = response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}"
3247
+
3248
+ return litellm.ModelResponse(
3249
+ **{
3250
+ "id": chunk_id,
3251
+ "object": "chat.completion.chunk",
3252
+ "created": int(time.time()),
3253
+ "model": model,
3254
+ "choices": [
3255
+ {
3256
+ "index": 0,
3257
+ "delta": {
3258
+ "role": "assistant",
3259
+ "content": None,
3260
+ "tool_calls": [
3261
+ {
3262
+ "index": 0,
3263
+ "id": tool_id,
3264
+ "type": "function",
3265
+ "function": {
3266
+ "name": tool_name,
3267
+ "arguments": fixed_json,
3268
+ },
3269
+ }
3270
+ ],
3271
+ },
3272
+ "finish_reason": "tool_calls",
3273
+ }
3274
+ ],
3275
+ }
3276
+ )
3277
+
3278
  def _translate_tool_choice(
3279
  self, tool_choice: Union[str, Dict[str, Any]], model: str = ""
3280
  ) -> Optional[Dict[str, Any]]:
 
4102
  )
4103
  file_logger.log_request(payload)
4104
 
4105
+ # Pre-build tool schema map for malformed call handling
4106
+ # This maps original tool names (without prefix) to their schemas
4107
+ tool_schemas = self._build_tool_schema_map(gemini_payload.get("tools"), model)
4108
+
4109
  # Make API call
4110
  base_url = self._get_base_url()
4111
  endpoint = ":streamGenerateContent" if stream else ":generateContent"
 
4123
  **ANTIGRAVITY_HEADERS,
4124
  }
4125
 
4126
+ # Track malformed call retries (separate from empty response retries)
4127
+ malformed_retry_count = 0
4128
+ # Keep a mutable reference to gemini_contents for retry injection
4129
+ current_gemini_contents = gemini_contents
4130
+
4131
  # URL fallback loop - handles HTTP errors (except 429) and network errors
4132
  # by switching to fallback URLs. Empty response retry is handled separately
4133
  # inside _streaming_with_retry (streaming) or the inner loop (non-streaming).
 
4142
  payload,
4143
  model,
4144
  file_logger,
4145
+ tool_schemas,
4146
+ current_gemini_contents,
4147
+ gemini_payload,
4148
+ project_id,
4149
+ max_tokens,
4150
+ reasoning_effort,
4151
+ tool_choice,
4152
  )
4153
  else:
4154
+ # Non-streaming: empty response, bare 429, and malformed call retry
4155
  empty_error_msg = (
4156
  "The model returned an empty response after multiple attempts. "
4157
  "This may indicate a temporary service issue. Please try again."
 
4199
 
4200
  return result
4201
 
4202
+ except _MalformedFunctionCallDetected as e:
4203
+ # Handle MALFORMED_FUNCTION_CALL - try auto-fix first
4204
+ parsed = self._parse_malformed_call_message(
4205
+ e.finish_message, model
4206
+ )
4207
+
4208
+ if parsed:
4209
+ # Try to auto-fix the malformed JSON
4210
+ error_info = self._analyze_json_error(
4211
+ parsed["raw_args"]
4212
+ )
4213
+
4214
+ if error_info.get("fixed_json"):
4215
+ # Auto-fix successful - build synthetic response
4216
+ lib_logger.info(
4217
+ f"[Antigravity] Auto-fixed malformed function call for "
4218
+ f"'{parsed['tool_name']}' from {model}"
4219
+ )
4220
+
4221
+ # Log the auto-fix details
4222
+ if file_logger:
4223
+ file_logger.log_malformed_autofix(
4224
+ parsed["tool_name"],
4225
+ parsed["raw_args"],
4226
+ error_info["fixed_json"],
4227
+ )
4228
+
4229
+ fixed_response = (
4230
+ self._build_fixed_tool_call_response(
4231
+ model, parsed, error_info
4232
+ )
4233
+ )
4234
+ if fixed_response:
4235
+ return fixed_response
4236
+
4237
+ # Auto-fix failed - retry by asking model to fix its JSON
4238
+ # Each retry response will also attempt auto-fix first
4239
+ if malformed_retry_count < MALFORMED_CALL_MAX_RETRIES:
4240
+ malformed_retry_count += 1
4241
+ lib_logger.warning(
4242
+ f"[Antigravity] MALFORMED_FUNCTION_CALL from {model}, "
4243
+ f"retry {malformed_retry_count}/{MALFORMED_CALL_MAX_RETRIES}: "
4244
+ f"{e.finish_message[:100]}..."
4245
+ )
4246
+
4247
+ if parsed:
4248
+ # Get schema for the failed tool
4249
+ tool_schema = tool_schemas.get(parsed["tool_name"])
4250
+
4251
+ # Build corrective messages
4252
+ assistant_msg, user_msg = (
4253
+ self._build_malformed_call_retry_messages(
4254
+ parsed, tool_schema
4255
+ )
4256
+ )
4257
+
4258
+ # Inject into conversation
4259
+ current_gemini_contents = list(
4260
+ current_gemini_contents
4261
+ )
4262
+ current_gemini_contents.append(assistant_msg)
4263
+ current_gemini_contents.append(user_msg)
4264
+
4265
+ # Rebuild payload with modified contents
4266
+ gemini_payload_copy = copy.deepcopy(gemini_payload)
4267
+ gemini_payload_copy["contents"] = (
4268
+ current_gemini_contents
4269
+ )
4270
+ payload = self._transform_to_antigravity_format(
4271
+ gemini_payload_copy,
4272
+ model,
4273
+ project_id,
4274
+ max_tokens,
4275
+ reasoning_effort,
4276
+ tool_choice,
4277
+ )
4278
+
4279
+ # Log the retry request in the same folder
4280
+ if file_logger:
4281
+ file_logger.log_malformed_retry_request(
4282
+ malformed_retry_count, payload
4283
+ )
4284
+
4285
+ await asyncio.sleep(MALFORMED_CALL_RETRY_DELAY)
4286
+ break # Break inner loop to retry with modified payload
4287
+ else:
4288
+ # Auto-fix failed and retries disabled/exceeded - return fallback
4289
+ lib_logger.warning(
4290
+ f"[Antigravity] MALFORMED_FUNCTION_CALL could not be auto-fixed "
4291
+ f"for {model}: {e.finish_message[:100]}..."
4292
+ )
4293
+ return self._build_malformed_fallback_response(
4294
+ model, e.finish_message
4295
+ )
4296
+
4297
  except httpx.HTTPStatusError as e:
4298
  if e.response.status_code == 429:
4299
  # Check if this is a bare 429 (no retry info) vs real quota exhaustion
 
4320
  )
4321
  # Re-raise all HTTP errors (429 with retry info, or other errors)
4322
  raise
4323
+ else:
4324
+ # For loop completed normally (no break) - should not happen
4325
+ # This means we exhausted EMPTY_RESPONSE_MAX_ATTEMPTS without success
4326
+ lib_logger.error(
4327
+ f"[Antigravity] Unexpected exit from retry loop for {model}"
4328
+ )
4329
+ raise EmptyResponseError(
4330
+ provider="antigravity",
4331
+ model=model,
4332
+ message=empty_error_msg,
4333
+ )
4334
+ # If we broke out of the for loop (malformed retry), continue while loop
4335
+ continue
4336
 
4337
  except httpx.HTTPStatusError as e:
4338
  # 429 = Rate limit/quota exhausted - tied to credential, not URL
 
4414
 
4415
  gemini_response = self._unwrap_response(data)
4416
 
4417
+ # Check for MALFORMED_FUNCTION_CALL before conversion
4418
+ malformed_msg = self._check_for_malformed_call(gemini_response)
4419
+ if malformed_msg:
4420
+ raise _MalformedFunctionCallDetected(malformed_msg, gemini_response)
4421
+
4422
  # Build tool schema map for schema-aware JSON parsing
4423
  tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
4424
  openai_response = self._gemini_to_openai_non_streaming(
 
4435
  payload: Dict[str, Any],
4436
  model: str,
4437
  file_logger: Optional[AntigravityFileLogger] = None,
4438
+ malformed_retry_num: Optional[int] = None,
4439
  ) -> AsyncGenerator[litellm.ModelResponse, None]:
4440
+ """Handle streaming completion.
4441
+
4442
+ Args:
4443
+ malformed_retry_num: If set, log response chunks to malformed_retry_N_response.log
4444
+ instead of the main response_stream.log
4445
+ """
4446
  # Build tool schema map for schema-aware JSON parsing
4447
  tool_schemas = self._build_tool_schema_map(payload.get("tools"), model)
4448
 
 
4457
  "last_usage": None, # Track last received usage for final chunk
4458
  "yielded_any": False, # Track if we yielded any real chunks
4459
  "tool_schemas": tool_schemas, # For schema-aware JSON string parsing
4460
+ "malformed_call": None, # Track MALFORMED_FUNCTION_CALL if detected
4461
+ "response_id": None, # Track original response ID for synthetic chunks
4462
  }
4463
 
4464
  async with client.stream(
 
4483
 
4484
  async for line in response.aiter_lines():
4485
  if file_logger:
4486
+ if malformed_retry_num is not None:
4487
+ file_logger.log_malformed_retry_response(
4488
+ malformed_retry_num, line
4489
+ )
4490
+ else:
4491
+ file_logger.log_response_chunk(line)
4492
 
4493
  if line.startswith("data: "):
4494
  data_str = line[6:]
 
4498
  try:
4499
  chunk = json.loads(data_str)
4500
  gemini_chunk = self._unwrap_response(chunk)
4501
+
4502
+ # Capture response ID from first chunk for synthetic responses
4503
+ if not accumulator.get("response_id"):
4504
+ accumulator["response_id"] = gemini_chunk.get("responseId")
4505
+
4506
+ # Check for MALFORMED_FUNCTION_CALL
4507
+ malformed_msg = self._check_for_malformed_call(gemini_chunk)
4508
+ if malformed_msg:
4509
+ # Store for retry handler, don't yield anything more
4510
+ accumulator["malformed_call"] = malformed_msg
4511
+ break
4512
+
4513
  openai_chunk = self._gemini_to_openai_chunk(
4514
  gemini_chunk, model, accumulator
4515
  )
 
4521
  file_logger.log_error(f"Parse error: {data_str[:100]}")
4522
  continue
4523
 
4524
+ # Check if we detected a malformed call - raise exception for retry handler
4525
+ if accumulator.get("malformed_call"):
4526
+ raise _MalformedFunctionCallDetected(
4527
+ accumulator["malformed_call"],
4528
+ {"accumulator": accumulator},
4529
+ )
4530
+
4531
  # Only emit synthetic final chunk if we actually received real data
4532
  # If no data was received, the caller will detect zero chunks and retry
4533
  if accumulator.get("yielded_any"):
 
4566
  payload: Dict[str, Any],
4567
  model: str,
4568
  file_logger: Optional[AntigravityFileLogger] = None,
4569
+ tool_schemas: Optional[Dict[str, Dict[str, Any]]] = None,
4570
+ gemini_contents: Optional[List[Dict[str, Any]]] = None,
4571
+ gemini_payload: Optional[Dict[str, Any]] = None,
4572
+ project_id: Optional[str] = None,
4573
+ max_tokens: Optional[int] = None,
4574
+ reasoning_effort: Optional[str] = None,
4575
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
4576
  ) -> AsyncGenerator[litellm.ModelResponse, None]:
4577
  """
4578
+ Wrapper around _handle_streaming that retries on empty responses, bare 429s,
4579
+ and MALFORMED_FUNCTION_CALL errors.
4580
 
4581
  If the stream yields zero chunks (Antigravity returned nothing) or encounters
4582
  a bare 429 (no retry info), retry up to EMPTY_RESPONSE_MAX_ATTEMPTS times
4583
  before giving up.
4584
+
4585
+ If MALFORMED_FUNCTION_CALL is detected, inject corrective messages and retry
4586
+ up to MALFORMED_CALL_MAX_RETRIES times.
4587
  """
4588
  empty_error_msg = (
4589
  "The model returned an empty response after multiple attempts. "
 
4594
  "This may indicate a temporary service issue. Please try again."
4595
  )
4596
 
4597
+ # Track malformed call retries (separate from empty response retries)
4598
+ malformed_retry_count = 0
4599
+ current_gemini_contents = gemini_contents
4600
+ current_payload = payload
4601
+
4602
  for attempt in range(EMPTY_RESPONSE_MAX_ATTEMPTS):
4603
  chunk_count = 0
4604
 
4605
  try:
4606
+ # Pass malformed_retry_count to log response to separate file
4607
+ retry_num = malformed_retry_count if malformed_retry_count > 0 else None
4608
  async for chunk in self._handle_streaming(
4609
  client,
4610
  url,
4611
  headers,
4612
+ current_payload,
4613
  model,
4614
  file_logger,
4615
+ malformed_retry_num=retry_num,
4616
  ):
4617
  chunk_count += 1
4618
  yield chunk # Stream immediately - true streaming preserved
 
4637
  message=empty_error_msg,
4638
  )
4639
 
4640
+ except _MalformedFunctionCallDetected as e:
4641
+ # Handle MALFORMED_FUNCTION_CALL - try auto-fix first
4642
+ parsed = self._parse_malformed_call_message(e.finish_message, model)
4643
+
4644
+ if parsed:
4645
+ # Try to auto-fix the malformed JSON
4646
+ error_info = self._analyze_json_error(parsed["raw_args"])
4647
+
4648
+ if error_info.get("fixed_json"):
4649
+ # Auto-fix successful - build synthetic response
4650
+ lib_logger.info(
4651
+ f"[Antigravity] Auto-fixed malformed function call for "
4652
+ f"'{parsed['tool_name']}' from {model} (streaming)"
4653
+ )
4654
+
4655
+ # Log the auto-fix details
4656
+ if file_logger:
4657
+ file_logger.log_malformed_autofix(
4658
+ parsed["tool_name"],
4659
+ parsed["raw_args"],
4660
+ error_info["fixed_json"],
4661
+ )
4662
+
4663
+ # Extract response_id from accumulator in exception
4664
+ response_id = None
4665
+ if e.raw_response and isinstance(e.raw_response, dict):
4666
+ acc = e.raw_response.get("accumulator", {})
4667
+ response_id = acc.get("response_id")
4668
+
4669
+ # Use chunk format for streaming with original response ID
4670
+ fixed_chunk = self._build_fixed_tool_call_chunk(
4671
+ model, parsed, error_info, response_id=response_id
4672
+ )
4673
+ if fixed_chunk:
4674
+ yield fixed_chunk
4675
+ return
4676
+
4677
+ # Auto-fix failed - retry by asking model to fix its JSON
4678
+ # Each retry response will also attempt auto-fix first
4679
+ if malformed_retry_count < MALFORMED_CALL_MAX_RETRIES:
4680
+ malformed_retry_count += 1
4681
+ lib_logger.warning(
4682
+ f"[Antigravity] MALFORMED_FUNCTION_CALL from {model} (streaming), "
4683
+ f"retry {malformed_retry_count}/{MALFORMED_CALL_MAX_RETRIES}: "
4684
+ f"{e.finish_message[:100]}..."
4685
+ )
4686
+
4687
+ if parsed and gemini_payload is not None:
4688
+ # Get schema for the failed tool
4689
+ tool_schema = (
4690
+ tool_schemas.get(parsed["tool_name"])
4691
+ if tool_schemas
4692
+ else None
4693
+ )
4694
+
4695
+ # Build corrective messages
4696
+ assistant_msg, user_msg = (
4697
+ self._build_malformed_call_retry_messages(
4698
+ parsed, tool_schema
4699
+ )
4700
+ )
4701
+
4702
+ # Inject into conversation
4703
+ current_gemini_contents = list(current_gemini_contents or [])
4704
+ current_gemini_contents.append(assistant_msg)
4705
+ current_gemini_contents.append(user_msg)
4706
+
4707
+ # Rebuild payload with modified contents
4708
+ gemini_payload_copy = copy.deepcopy(gemini_payload)
4709
+ gemini_payload_copy["contents"] = current_gemini_contents
4710
+ current_payload = self._transform_to_antigravity_format(
4711
+ gemini_payload_copy,
4712
+ model,
4713
+ project_id or "",
4714
+ max_tokens,
4715
+ reasoning_effort,
4716
+ tool_choice,
4717
+ )
4718
+
4719
+ # Log the retry request in the same folder
4720
+ if file_logger:
4721
+ file_logger.log_malformed_retry_request(
4722
+ malformed_retry_count, current_payload
4723
+ )
4724
+
4725
+ await asyncio.sleep(MALFORMED_CALL_RETRY_DELAY)
4726
+ continue # Retry with modified payload
4727
+ else:
4728
+ # Auto-fix failed and retries disabled/exceeded - yield fallback response
4729
+ lib_logger.warning(
4730
+ f"[Antigravity] MALFORMED_FUNCTION_CALL could not be auto-fixed "
4731
+ f"for {model} (streaming): {e.finish_message[:100]}..."
4732
+ )
4733
+ fallback = self._build_malformed_fallback_response(
4734
+ model, e.finish_message
4735
+ )
4736
+ yield fallback
4737
+ return
4738
+
4739
  except httpx.HTTPStatusError as e:
4740
  if e.response.status_code == 429:
4741
  # Check if this is a bare 429 (no retry info) vs real quota exhaustion