`hermes peer dm` posts to POST /api/sessions/{id}/chat, the third Bot-DM
transport. The local (`tools.bot_mode_dm`) and relayed
(`tui_gateway.methods_bot_relay`) lanes both re-run a transiently failed turn
once under the shared policy (`tools.bot_failure_reasons.retry_action`) and
resume the row the failed attempt left as the transcript's unanswered tail;
this lane ran the turn once and handed the provider's 429 paragraph to the
sender as the reply (#115325).
The policy is asked about a result dict now, not two streams: `result_retry_action`
joins `error` + `failure_reason` (the turn loop's own typed verdict) so one
classifier serves every lane, and the server-error rule accepts the providers'
`server_error` / `overloaded_error` spellings — the codes the in-process lanes
key on instead of a status number.
The resume half is the CLI lane's rule extracted to `agent.session_persistence.
adopt_unanswered_turn`, which `quiet_single_query` (env-gated dispatcher re-run)
and the API lane (in-process re-run, on the agent it just built) now share.
The regression drives the real route and the real `_run_agent` over a real
store: a 429 re-runs the same DM once with the persisted row adopted as this
turn's user message (so no second copy), a 401 still reports one attempt.
(cherry picked from commit 8fe6d46ada5b8064bc7132ee956fda998244c10a)
122 lines
5.4 KiB
Python
122 lines
5.4 KiB
Python
"""#115325: the peer-DM transport (``POST /api/sessions/{id}/chat``) retries a transiently failed
|
|
turn once, resuming the DM row the failed attempt already persisted.
|
|
|
|
``hermes peer dm`` is the third Bot-DM transport. The local (``tools.bot_mode_dm``) and relayed
|
|
(``tui_gateway.methods_bot_relay``) lanes both re-run a transiently failed turn once and resume the
|
|
row the failed attempt left as the transcript's unanswered tail (``tools/bot_failure_reasons``
|
|
``retry_action`` / ``RESUME_UNANSWERED_TURN``); the peer lane ran the turn once and handed whatever
|
|
came back — the provider's 429 paragraph — to the sender as the reply.
|
|
|
|
Only the model turn is faked (``_create_agent`` hands out recording agents); the route, the store and
|
|
``_run_agent`` are the real ones, so both halves of the contract are asserted on what the retry was
|
|
actually handed: the persisted row adopted as this turn's user message, and no second attempt for a
|
|
failure the policy never retries.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import TestClient, TestServer
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.api_server import APIServerAdapter
|
|
from hermes_state import SessionDB
|
|
|
|
SESSION_ID = "peer_dm_retry"
|
|
DM = "disk status?"
|
|
|
|
# Transient: the policy's own class (429 / rate limit).
|
|
RATE_LIMIT = {
|
|
"final_response": "Rate limited by the provider. API call failed after 3 retries: 429 Too Many Requests",
|
|
"failed": True, "completed": False, "error": "429 Too Many Requests",
|
|
"failure_reason": "rate_limit", "messages": [], "api_calls": 3,
|
|
}
|
|
# Permanent: a wall no re-run can fix.
|
|
AUTH_WALL = {
|
|
"final_response": "Provider authentication failed: invalid api key",
|
|
"failed": True, "completed": False, "error": "Error code: 401 - invalid_api_key",
|
|
"failure_reason": "auth", "messages": [], "api_calls": 1,
|
|
}
|
|
|
|
|
|
def _ok(text: str) -> dict:
|
|
return {"final_response": text, "failed": False, "completed": True,
|
|
"messages": [], "api_calls": 1}
|
|
|
|
|
|
def _app(adapter: APIServerAdapter) -> web.Application:
|
|
app = web.Application()
|
|
app.router.add_post("/api/sessions/{session_id}/chat", adapter._handle_session_chat)
|
|
return app
|
|
|
|
|
|
def _adapter(tmp_path) -> tuple[APIServerAdapter, str]:
|
|
"""A real adapter over a real store, holding the DM row a failed attempt's turn-start persist
|
|
would have left as the transcript's unanswered tail."""
|
|
db = SessionDB(tmp_path / "state.db")
|
|
sid = db.create_session(SESSION_ID, "api_server")
|
|
db.append_message(sid, "user", content=DM)
|
|
adapter = APIServerAdapter(PlatformConfig(enabled=True))
|
|
adapter._session_db = db
|
|
return adapter, sid
|
|
|
|
|
|
def _fake_agent(seen: list, outcome: dict) -> MagicMock:
|
|
"""Records what each turn was handed; ``_pending_cli_user_message`` is the adopted row."""
|
|
agent = MagicMock()
|
|
agent.session_id = SESSION_ID
|
|
agent.session_prompt_tokens = 0
|
|
agent.session_completion_tokens = 0
|
|
agent.session_total_tokens = 0
|
|
|
|
def _run(user_message=None, conversation_history=None, task_id=None, **_kwargs):
|
|
seen.append({
|
|
"message": user_message,
|
|
"history": list(conversation_history or []),
|
|
"resumed": getattr(agent, "_pending_cli_user_message", None),
|
|
})
|
|
return dict(outcome)
|
|
|
|
agent.run_conversation.side_effect = _run
|
|
return agent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_peer_dm_retries_a_transient_turn_once_and_still_reports_a_permanent_one(tmp_path):
|
|
adapter, sid = _adapter(tmp_path)
|
|
app = _app(adapter)
|
|
|
|
# ① Transient failure -> re-run once, resuming the persisted DM row, and the recovered reply is
|
|
# what the peer client receives.
|
|
seen: list = []
|
|
agents = [_fake_agent(seen, RATE_LIMIT), _fake_agent(seen, _ok("2+2 is 4"))]
|
|
with patch.object(adapter, "_create_agent", side_effect=lambda **_kw: agents.pop(0)):
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(f"/api/sessions/{sid}/chat", json={"message": DM})
|
|
body = await resp.json()
|
|
|
|
assert resp.status == 200
|
|
assert len(seen) == 2, "a transiently failed peer DM must be re-run exactly once"
|
|
assert body["message"]["content"] == "2+2 is 4"
|
|
retry = seen[1]
|
|
assert retry["message"] == DM, "the re-run replays the same DM"
|
|
resumed = retry["resumed"]
|
|
assert isinstance(resumed, dict) and resumed.get("content") == DM, (
|
|
"the re-run must resume the row the failed attempt persisted as the pending user message")
|
|
assert resumed.get("_db_persisted") is True, "the resumed row is the durable one, not a copy"
|
|
assert all(row.get("content") != DM for row in retry["history"]), (
|
|
"the resumed row leaves the history, so the turn appends no second copy of the DM")
|
|
|
|
# ② Permanent failure -> reported exactly as before: one attempt, its error copy as the reply.
|
|
seen2: list = []
|
|
walls = [_fake_agent(seen2, AUTH_WALL), _fake_agent(seen2, AUTH_WALL)]
|
|
with patch.object(adapter, "_create_agent", side_effect=lambda **_kw: walls.pop(0)):
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp2 = await cli.post(f"/api/sessions/{sid}/chat", json={"message": DM})
|
|
body2 = await resp2.json()
|
|
|
|
assert resp2.status == 200
|
|
assert len(seen2) == 1, "auth is never auto-retried: it cannot be fixed by a re-run"
|
|
assert body2["message"]["content"] == AUTH_WALL["final_response"]
|