Spaces:
Running
Running
SilverElixir commited on
Commit ·
7bea82f
1
Parent(s): d926362
Retry tool-only Gemini replies instead of leaking tool JSON
Browse files- lumen_routes.py +7 -6
- 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 |
# Повтор после битого вызова сам упал (а не просто не влез в бюджет) или
|