fix(agent): validate non-empty retry delays; pin exhaustion semantics
核心算法 #10(Agent Loop):Codex 质量审查跟进,仅加固防御与 可观测,不改变循环语义。 - __init__ 校验 step_retry_delays 非空,空序列直接抛 ValueError (P5 fail-fast,避免首次可重试异常时 IndexError 掩盖原始 LLM 异常、违背方法契约) - 补耗尽语义窄测试(耗尽后原样抛出最后一次原始异常)与空序列 构造校验测试 - run() 最终失败日志补异常类型名,便于回溯归因
This commit is contained in:
+5
-2
@@ -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,
|
||||
|
||||
@@ -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=())
|
||||
|
||||
Reference in New Issue
Block a user