SilverElixir commited on
Commit
7bea82f
·
1 Parent(s): d926362

Retry tool-only Gemini replies instead of leaking tool JSON

Browse files
Files changed (2) hide show
  1. lumen_routes.py +7 -6
  2. tests/test_bot_routes.py +29 -0
lumen_routes.py CHANGED
@@ -695,12 +695,13 @@ async def _extract_gemini_answer_text(resp: Any, *, model_id: str, call_contents
695
  tool_calls.append(f"response:{json.dumps(bot._json_prune_defaults(getattr(fn_resp, 'response', None)), ensure_ascii=False)}")
696
  except Exception:
697
  tool_calls.append("response")
698
- if not ans and tool_calls:
699
- ans = "[Tool call: " + "; ".join(tool_calls) + "]"
700
- elif not ans and reasons:
701
  retry_failed = False
702
- if any("MALFORMED_FUNCTION_CALL" in r for r in reasons):
703
- # Модель сломала свой вызов инструмента — повторяем без инструментов.
 
 
 
704
  try:
705
  retry_gconfig = gconfig.model_copy(update={"tools": None}) if gconfig is not None else None
706
  # Async-клиент, а не to_thread: wait_for реально отменяет зависший
@@ -737,7 +738,7 @@ async def _extract_gemini_answer_text(resp: Any, *, model_id: str, call_contents
737
  # модель, а не показывать заглушку блокировки как готовый ответ
738
  # (иначе цепочка останавливалась на первой же битой модели).
739
  retry_failed = True
740
- if not ans and not retry_failed:
741
  ans = f"[Ответ заблокирован или пуст. Причина: {', '.join(reasons)}]"
742
  # Пустая строка без блокировки — не "Empty response": ask_gemini пробует следующую модель.
743
  return ans.strip()
 
695
  tool_calls.append(f"response:{json.dumps(bot._json_prune_defaults(getattr(fn_resp, 'response', None)), ensure_ascii=False)}")
696
  except Exception:
697
  tool_calls.append("response")
698
+ if not ans and (tool_calls or reasons):
 
 
699
  retry_failed = False
700
+ if tool_calls or any("MALFORMED_FUNCTION_CALL" in r for r in reasons):
701
+ # Ответ без текста: битый вызов или голый tool_call — пользователю
702
+ # JSON инструментов не показываем (прод 07.10.2026), повторяем без них.
703
+ if tool_calls:
704
+ log.warning("[gemini] Model %s returned only tool calls without text, retrying without tools.", model_id)
705
  try:
706
  retry_gconfig = gconfig.model_copy(update={"tools": None}) if gconfig is not None else None
707
  # Async-клиент, а не to_thread: wait_for реально отменяет зависший
 
738
  # модель, а не показывать заглушку блокировки как готовый ответ
739
  # (иначе цепочка останавливалась на первой же битой модели).
740
  retry_failed = True
741
+ if not ans and not retry_failed and reasons:
742
  ans = f"[Ответ заблокирован или пуст. Причина: {', '.join(reasons)}]"
743
  # Пустая строка без блокировки — не "Empty response": ask_gemini пробует следующую модель.
744
  return ans.strip()
tests/test_bot_routes.py CHANGED
@@ -376,6 +376,35 @@ def test_extract_gemini_answer_reports_non_malformed_block_without_retry_state()
376
  assert "SAFETY" in ans
377
 
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  @pytest.mark.parametrize("retry_outcome", ["raises", "empty"])
380
  def test_extract_gemini_answer_failed_retry_goes_to_next_model(retry_outcome):
381
  # Повтор после битого вызова сам упал (а не просто не влез в бюджет) или
 
376
  assert "SAFETY" in ans
377
 
378
 
379
+ def test_extract_gemini_answer_retries_without_tools_on_tool_only_response():
380
+ # Прод 07.10.2026: модель вернула только function_call без текста, юзер видел "[Tool call: ...]".
381
+ from types import SimpleNamespace
382
+ fn_part = SimpleNamespace(
383
+ text="",
384
+ function_call=SimpleNamespace(name="google_search", args={"query": "test"}),
385
+ function_response=None,
386
+ )
387
+ content = SimpleNamespace(parts=[fn_part])
388
+ resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="STOP", content=content)])
389
+
390
+ async def retry_ok(*, model, contents, config=None):
391
+ assert config is None or getattr(config, "tools", None) is None
392
+ return _FakeGeminiResponse(text="Ответ без инструментов")
393
+
394
+ fake_client = MagicMock()
395
+ fake_client.aio.models.generate_content = retry_ok
396
+ original_client = bot.client
397
+ bot.client = fake_client
398
+ try:
399
+ ans = asyncio.run(bot._extract_gemini_answer_text(
400
+ resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
401
+ ))
402
+ assert ans == "Ответ без инструментов"
403
+ assert "Tool call" not in ans
404
+ finally:
405
+ bot.client = original_client
406
+
407
+
408
  @pytest.mark.parametrize("retry_outcome", ["raises", "empty"])
409
  def test_extract_gemini_answer_failed_retry_goes_to_next_model(retry_outcome):
410
  # Повтор после битого вызова сам упал (а не просто не влез в бюджет) или