Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6675,15 +6675,47 @@ def _perform_api_call(next_api_kwargs):
)
if _truly_empty and (not _has_structured or _prefill_exhausted) and agent._empty_content_retries < 3:
agent._empty_content_retries += 1
wait_time = jittered_backoff(
agent._empty_content_retries,
base_delay=5.0,
max_delay=60.0,
)
logger.warning(
"Empty response (no content or reasoning) — "
"retry %d/3 (model=%s)",
agent._empty_content_retries, agent.model,
"retry %d/3 in %.1fs (model=%s)",
agent._empty_content_retries, wait_time, agent.model,
)
agent._buffer_status(
f"⚠️ Empty response from model — retrying "
f"({agent._empty_content_retries}/3)"
f"({agent._empty_content_retries}/3) in {wait_time:.0f}s"
)
# Sleep in small increments to stay responsive to interrupts
sleep_end = time.time() + wait_time
_backoff_touch_counter = 0
while time.time() < sleep_end:
if agent._interrupt_requested:
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during empty-response retry wait, aborting.", force=True)
_interrupt_text = (
f"Operation interrupted: retrying empty response from model "
f"(retry {agent._empty_content_retries}/3)."
)
close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
agent.clear_interrupt()
return {
"final_response": _interrupt_text,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"interrupted": True,
}
time.sleep(0.2)
_backoff_touch_counter += 1
if _backoff_touch_counter % 150 == 0: # 150 × 0.2s = 30s
agent._touch_activity(
f"empty response retry backoff ({agent._empty_content_retries}/3), "
f"{int(sleep_end - time.time())}s remaining"
)
continue

# ── Exhausted retries — try fallback provider ──
Expand Down
87 changes: 87 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3207,6 +3207,93 @@ def _mock_fallback():
assert "No reply:" in result["final_response"]


def test_empty_response_retry_backoff_interrupted(self, agent, monkeypatch):
"""If an interrupt is requested during the empty response retry wait, we abort."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
empty_resp = _mock_response(content=None, finish_reason="stop")
agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp]

from agent import conversation_loop as _conv_loop

# Make backoff return 10.0 seconds
monkeypatch.setattr(_conv_loop, "jittered_backoff", lambda *a, **k: 10.0)

# Trigger the interrupt on the first sleep call inside the wait loop
original_sleep = time.sleep
sleep_called = []

def _mock_sleep(seconds):
sleep_called.append(seconds)
if seconds == 0.2:
agent._interrupt_requested = True
else:
original_sleep(seconds)

monkeypatch.setattr(time, "sleep", _mock_sleep)

with (
patch.object(agent, "_persist_session") as mock_persist,
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("answer me")

assert result["interrupted"] is True
assert "Operation interrupted: retrying empty response from model" in result["final_response"]
assert agent._empty_content_retries == 1
assert 0.2 in sleep_called
assert mock_persist.call_count == 2

def test_empty_response_retry_backoff_status(self, agent, monkeypatch):
"""Empty response retry wait updates the agent's status with wait time and sleeps."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"

# Two responses: first empty, second succeeds so it doesn't run forever
empty_resp = _mock_response(content=None, finish_reason="stop")
ok_resp = _mock_response(content="Final ok response.", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [empty_resp, ok_resp]

from agent import conversation_loop as _conv_loop

monkeypatch.setattr(_conv_loop, "jittered_backoff", lambda *a, **k: 7.5)

# Fake clock: the retry loop gates on real time.time() < sleep_end, so
# a no-op sleep alone busy-spins 7.5 wall-clock seconds. Advance a fake
# clock by each sleep amount instead (established pattern:
# test_session_activity_persist.py patches run_agent.time.time).
clock = {"t": time.time()}
monkeypatch.setattr(_conv_loop.time, "time", lambda: clock["t"])

sleep_calls = []

def _fake_sleep(secs):
sleep_calls.append(secs)
clock["t"] += secs

monkeypatch.setattr(time, "sleep", _fake_sleep)
monkeypatch.setattr(_conv_loop.time, "sleep", _fake_sleep)

status_messages = []
monkeypatch.setattr(agent, "_buffer_status", lambda status: status_messages.append(status))

with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("answer me")

assert result["completed"] is True
assert result["final_response"] == "Final ok response."

# 7.5s wait, slept in 0.2s increments -> 37.5 -> at least 37 calls
assert len([c for c in sleep_calls if c == 0.2]) >= 37

retry_status = [m for m in status_messages if "Empty response from model — retrying (1/3) in 8s" in m]
assert len(retry_status) == 1

def test_partial_stream_recovery_uses_streamed_content(self, agent):
"""When streaming fails after partial delivery, recovered partial content becomes final response."""
self._setup_agent(agent)
Expand Down
Loading