diff --git a/core/agent/loop.py b/core/agent/loop.py index c6a8d53..405292d 100644 --- a/core/agent/loop.py +++ b/core/agent/loop.py @@ -73,7 +73,8 @@ class AgentLoop: max_steps: 最大有效步数(每次成功工具调用计一步)。 max_retries: JSON 解析连续失败的最大容忍次数。 step_retries: LLM 瞬时异常的步级重试次数(不含首次调用)。 - step_retry_delays: 步级重试的退避秒数序列,超出部分取末值。 + step_retry_delays: 步级重试的退避秒数序列,超出部分取末值; + 不得为空(空序列构造时抛 ValueError,fail-fast)。 retryable_exceptions: 可重试异常元组,默认 (TimeoutError, OSError)—— ssl.SSLError/ConnectionError 均为 OSError 子类,覆盖穿透 GovernedLLMClient 内部重试栈的瞬时异常;openai API 类异常由 @@ -90,6 +91,8 @@ class AgentLoop: step_retry_delays: tuple[float, ...] = (20.0, 40.0), retryable_exceptions: tuple[type[BaseException], ...] = (TimeoutError, OSError), ) -> None: + if not step_retry_delays: + raise ValueError("step_retry_delays 不得为空") self._llm = llm self._max_steps = max_steps self._max_retries = max_retries @@ -138,7 +141,7 @@ class AgentLoop: messages, token_usage, session_id=session_id ) except Exception as e: - logger.error("LLM API 调用失败: {}", e) + logger.error("LLM API 调用失败({}): {}", type(e).__name__, e) result = LoopResult( steps=steps, steps_used=step_count, diff --git a/tests/unit/test_agent_loop.py b/tests/unit/test_agent_loop.py index fe00b06..b7e3c5b 100644 --- a/tests/unit/test_agent_loop.py +++ b/tests/unit/test_agent_loop.py @@ -363,3 +363,23 @@ class TestStepLevelRetry: assert result.stop_reason == "error" sleep_mock.assert_not_awaited() assert loop._llm.chat.await_count == 1 + + @pytest.mark.asyncio + async def test_exhaustion_raises_last_original_exception(self, monkeypatch) -> None: + """耗尽语义窄测试:_call_llm_with_step_retry 耗尽后原样抛出最后一次异常。""" + + async def _fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr("core.agent.loop.asyncio.sleep", _fake_sleep) + last_error = TimeoutError("t3-last") + loop = self._make_loop([TimeoutError("t1"), TimeoutError("t2"), last_error]) + token_usage = {"prompt_tokens": 0, "completion_tokens": 0} + with pytest.raises(TimeoutError) as exc_info: + await loop._call_llm_with_step_retry([], token_usage, session_id=None) + assert exc_info.value is last_error + + def test_empty_retry_delays_rejected_at_init(self) -> None: + """空 step_retry_delays 在构造时 fail-fast 抛 ValueError。""" + with pytest.raises(ValueError, match="step_retry_delays"): + AgentLoop(llm=AsyncMock(), max_steps=10, step_retry_delays=())