feat(agent): step-level retry for transient LLM errors (20s/40s backoff)
核心算法 #10(Agent Loop):仅加固异常路径的韧性兜底,不改变 解析协议、hook 时序与步数语义。benchmark 错题 796-3 显示一次 SSL BAD_RECORD_MAC 穿透 GovernedLLMClient 重试栈后废掉 13 步 已积累上下文;本次在 run() Phase 1 增加步级重试(默认 2 次, 20s/40s 退避),可重试异常限定 (TimeoutError, OSError),非可 重试异常照旧 fail-fast 整题终止,行为与现状一致(P5 显式异常)。 为满足 radon C 级复杂度约束,重试循环抽取为私有方法 _call_llm_with_step_retry,行为不变。
This commit is contained in:
@@ -8,6 +8,7 @@ thinking 捕获、token 累加、call_id 透传、pluggy hook。
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -305,3 +306,60 @@ class TestParseNormalization:
|
||||
def test_empty_content_still_rejected(self) -> None:
|
||||
"""空 content 照旧拒绝,归一化不改变该边界。"""
|
||||
assert self._parse("") is None
|
||||
|
||||
|
||||
# ── A2 步级重试测试(Spec-1)──────────────────────────────────
|
||||
|
||||
|
||||
class TestStepLevelRetry:
|
||||
"""LLM 瞬时异常的步级重试:可重试元组 / 退避 / fail-fast。"""
|
||||
|
||||
def _make_loop(self, chat_side_effects: list) -> AgentLoop:
|
||||
"""构造 chat 按序抛异常/返回响应的 AgentLoop。"""
|
||||
llm = AsyncMock()
|
||||
llm.chat = AsyncMock(side_effect=chat_side_effects)
|
||||
return AgentLoop(llm=llm, max_steps=10)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_error_retried_then_succeeds(self, monkeypatch) -> None:
|
||||
"""SSL/超时瞬时异常按 20s/40s 退避重试,第三次成功 → finished。"""
|
||||
delays: list[float] = []
|
||||
|
||||
async def _fake_sleep(seconds: float) -> None:
|
||||
delays.append(seconds)
|
||||
|
||||
monkeypatch.setattr("core.agent.loop.asyncio.sleep", _fake_sleep)
|
||||
loop = self._make_loop(
|
||||
[
|
||||
ssl.SSLError("SSLV3_ALERT_BAD_RECORD_MAC"),
|
||||
TimeoutError("watchdog"),
|
||||
_make_response(_submit_json()),
|
||||
]
|
||||
)
|
||||
result = await loop.run("sys", "user", _StubDispatcher())
|
||||
assert result.stop_reason == "finished"
|
||||
assert delays == [20.0, 40.0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_exhausted_terminates_with_error(self, monkeypatch) -> None:
|
||||
"""重试预算(首次 + 2 次)耗尽仍失败 → stop_reason=error。"""
|
||||
|
||||
async def _fake_sleep(seconds: float) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("core.agent.loop.asyncio.sleep", _fake_sleep)
|
||||
loop = self._make_loop([TimeoutError("t1"), TimeoutError("t2"), TimeoutError("t3")])
|
||||
result = await loop.run("sys", "user", _StubDispatcher())
|
||||
assert result.stop_reason == "error"
|
||||
assert loop._llm.chat.await_count == 3 # 首次 + 2 次重试
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_retryable_fails_fast(self, monkeypatch) -> None:
|
||||
"""非可重试异常不退避不重发,首次即终止 → stop_reason=error。"""
|
||||
sleep_mock = AsyncMock()
|
||||
monkeypatch.setattr("core.agent.loop.asyncio.sleep", sleep_mock)
|
||||
loop = self._make_loop([RuntimeError("programming bug")])
|
||||
result = await loop.run("sys", "user", _StubDispatcher())
|
||||
assert result.stop_reason == "error"
|
||||
sleep_mock.assert_not_awaited()
|
||||
assert loop._llm.chat.await_count == 1
|
||||
|
||||
Reference in New Issue
Block a user