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:
2026-07-11 08:44:47 -04:00
parent e3184c11f9
commit badfcce4cb
2 changed files with 25 additions and 2 deletions
+5 -2
View File
@@ -73,7 +73,8 @@ class AgentLoop:
max_steps: 最大有效步数(每次成功工具调用计一步)。 max_steps: 最大有效步数(每次成功工具调用计一步)。
max_retries: JSON 解析连续失败的最大容忍次数。 max_retries: JSON 解析连续失败的最大容忍次数。
step_retries: LLM 瞬时异常的步级重试次数(不含首次调用)。 step_retries: LLM 瞬时异常的步级重试次数(不含首次调用)。
step_retry_delays: 步级重试的退避秒数序列,超出部分取末值 step_retry_delays: 步级重试的退避秒数序列,超出部分取末值
不得为空(空序列构造时抛 ValueErrorfail-fast)。
retryable_exceptions: 可重试异常元组,默认 (TimeoutError, OSError)—— retryable_exceptions: 可重试异常元组,默认 (TimeoutError, OSError)——
ssl.SSLError/ConnectionError 均为 OSError 子类,覆盖穿透 ssl.SSLError/ConnectionError 均为 OSError 子类,覆盖穿透
GovernedLLMClient 内部重试栈的瞬时异常;openai API 类异常由 GovernedLLMClient 内部重试栈的瞬时异常;openai API 类异常由
@@ -90,6 +91,8 @@ class AgentLoop:
step_retry_delays: tuple[float, ...] = (20.0, 40.0), step_retry_delays: tuple[float, ...] = (20.0, 40.0),
retryable_exceptions: tuple[type[BaseException], ...] = (TimeoutError, OSError), retryable_exceptions: tuple[type[BaseException], ...] = (TimeoutError, OSError),
) -> None: ) -> None:
if not step_retry_delays:
raise ValueError("step_retry_delays 不得为空")
self._llm = llm self._llm = llm
self._max_steps = max_steps self._max_steps = max_steps
self._max_retries = max_retries self._max_retries = max_retries
@@ -138,7 +141,7 @@ class AgentLoop:
messages, token_usage, session_id=session_id messages, token_usage, session_id=session_id
) )
except Exception as e: except Exception as e:
logger.error("LLM API 调用失败: {}", e) logger.error("LLM API 调用失败{}: {}", type(e).__name__, e)
result = LoopResult( result = LoopResult(
steps=steps, steps=steps,
steps_used=step_count, steps_used=step_count,
+20
View File
@@ -363,3 +363,23 @@ class TestStepLevelRetry:
assert result.stop_reason == "error" assert result.stop_reason == "error"
sleep_mock.assert_not_awaited() sleep_mock.assert_not_awaited()
assert loop._llm.chat.await_count == 1 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=())