feat(rag): retry a failed LLM request before the first token lands — BOR_LLM_RETRIES/BOR_LLM_RETRY_DELAY with a live 'retrying' status
This commit is contained in:
+307
-7
@@ -8,15 +8,21 @@ removed the per-tool budgets, the assistant/tool message history), the
|
||||
kill switch (``agent_max_rounds=0`` single-call path), the round cap
|
||||
forcing a final no-tools answer (an always-calling stream and an
|
||||
always-rejected stream), re-lists and multi-reads executing without
|
||||
budgets, dedupe, unknown tool / missing args / unknown path, and the
|
||||
``<tools>`` prompt section (HIGH only).
|
||||
budgets, dedupe, unknown tool / missing args / unknown path, the
|
||||
``<tools>`` prompt section (HIGH only), and the phase-67 per-round
|
||||
retries (a dead-then-recovered round restarts before its first piece
|
||||
with a ``RetryPiece``; a mid-stream drop stays terminal — locked A2;
|
||||
the forced final no-tools call retries too; ``llm_retries=0`` is one
|
||||
plain attempt; retries are invisible to the round cap; consumer abandon
|
||||
mid-retry-sleep leaks nothing).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from copy import deepcopy
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -31,7 +37,7 @@ from app.rag.agent import (
|
||||
AgentHolder,
|
||||
run_agent,
|
||||
)
|
||||
from app.rag.llm import LLMClient, StreamPiece, ToolCallPiece
|
||||
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece
|
||||
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
|
||||
|
||||
|
||||
@@ -73,12 +79,12 @@ class ScriptedLLM:
|
||||
|
||||
|
||||
async def _run(
|
||||
llm: ScriptedLLM,
|
||||
llm: ScriptedLLM | FailingLLM,
|
||||
holder: AgentHolder,
|
||||
settings: Settings,
|
||||
seed_docs: list[Document] | None = None,
|
||||
) -> list[StreamPiece | ToolCallPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece] = []
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
cast("Session", None),
|
||||
@@ -541,6 +547,300 @@ def test_read_document_missing_arguments_refused(
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
|
||||
|
||||
# ---------- retries inside the agent loop (phase 67, locked A2) ----------
|
||||
|
||||
|
||||
class FailingLLM:
|
||||
"""A scripted fake whose Nth ``chat_stream`` call yields pieces and
|
||||
then raises (phase 67): ``attempts`` is a list of ``(pieces, error)``
|
||||
— an error after zero pieces = "the endpoint died before the first
|
||||
token"; after some pieces = a mid-stream drop. Records every
|
||||
request's messages/tools and the indices of the attempts whose stream
|
||||
teardown ran (``closed``)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attempts: list[tuple[list[StreamPiece | ToolCallPiece], Exception | None]],
|
||||
) -> None:
|
||||
self.attempts = list(attempts)
|
||||
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
|
||||
#: Indices of attempts whose stream teardown has run.
|
||||
self.closed: list[int] = []
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
index = len(self.requests)
|
||||
pieces, error = (
|
||||
self.attempts[index]
|
||||
if index < len(self.attempts)
|
||||
else ([], LLMError("script exhausted"))
|
||||
)
|
||||
self.requests.append(
|
||||
(deepcopy(messages), deepcopy(tools) if tools is not None else None)
|
||||
)
|
||||
return self._attempt(index, pieces, error)
|
||||
|
||||
async def _attempt(
|
||||
self,
|
||||
index: int,
|
||||
pieces: list[StreamPiece | ToolCallPiece],
|
||||
error: Exception | None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
try:
|
||||
for piece in pieces:
|
||||
yield piece
|
||||
if error is not None:
|
||||
raise error
|
||||
finally:
|
||||
self.closed.append(index)
|
||||
|
||||
|
||||
def _record_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
||||
"""Monkeypatch ``asyncio.sleep`` (what ``chat_stream_retried`` awaits
|
||||
for the flat pre-retry delay) and record every awaited delay."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
return sleeps
|
||||
|
||||
|
||||
def test_round_retried_before_first_piece(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A tool round that dies before its first piece is restarted with the
|
||||
same messages: the stream carries a RetryPiece BEFORE the tool call,
|
||||
the tool executes, the final answer streams, and the per-call log line
|
||||
is still emitted exactly once (retries are invisible to the loop)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[
|
||||
([], LLMError("connection refused")),
|
||||
([ToolCallPiece(id="call_1", name="list_documents", arguments={})], None),
|
||||
([StreamPiece("content", "Done!")], None),
|
||||
]
|
||||
)
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
with caplog.at_level(logging.INFO, logger="app.agent"):
|
||||
pieces = asyncio.run(
|
||||
_run(llm, holder, _settings(agent_max_rounds=2, llm_retry_delay=2.5))
|
||||
)
|
||||
assert pieces == [
|
||||
RetryPiece(2, 4), # default llm_retries=3 → 4 attempts
|
||||
ToolCallPiece(id="call_1", name="list_documents", arguments={}),
|
||||
StreamPiece("content", "Done!"),
|
||||
]
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == []
|
||||
# The restart is byte-identical: same messages, same tools offered.
|
||||
assert len(llm.requests) == 3
|
||||
assert llm.requests[0] == llm.requests[1]
|
||||
assert llm.requests[0][1] == AGENT_TOOLS
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # the answer round still offered
|
||||
# The flat delay was awaited exactly once, before the retry.
|
||||
assert sleeps == [2.5]
|
||||
tool_logs = [r for r in caplog.records if r.getMessage().startswith("agent tool=")]
|
||||
assert len(tool_logs) == 1 # the retry did not re-run the tool or log
|
||||
assert tool_logs[0].getMessage() == "agent tool=list_documents args={} round=1/2"
|
||||
|
||||
|
||||
def test_round_failure_after_first_piece_is_terminal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Locked A2: a round that already streamed a piece fails the turn —
|
||||
the LLMError propagates out of ``run_agent``, no RetryPiece, no
|
||||
sleep, no second request, and the holder is untouched (the tool
|
||||
never ran)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))]
|
||||
)
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
|
||||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
with pytest.raises(LLMError, match="mid-stream drop"):
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
cast("Session", None),
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=_settings(),
|
||||
holder=holder,
|
||||
):
|
||||
out.append(piece)
|
||||
return out
|
||||
|
||||
out = asyncio.run(drain())
|
||||
assert out == [StreamPiece("content", "partial ")] # no RetryPiece
|
||||
assert len(llm.requests) == 1 # no retry
|
||||
assert sleeps == []
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
|
||||
|
||||
def test_forced_final_no_tools_call_is_retried(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The forced final request (round cap reached) goes through the same
|
||||
retry rule: a failure before its first piece yields a RetryPiece and
|
||||
restarts with ``tools=None``; the answer from the retry streams."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[
|
||||
([ToolCallPiece(id="call_1", name="list_documents", arguments={})], None),
|
||||
([ToolCallPiece(id="call_2", name="list_documents", arguments={})], None),
|
||||
([], LLMError("down at the cap")),
|
||||
([StreamPiece("content", "forced answer")], None),
|
||||
]
|
||||
)
|
||||
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2)))
|
||||
assert [type(p) for p in pieces] == [
|
||||
ToolCallPiece,
|
||||
ToolCallPiece,
|
||||
RetryPiece,
|
||||
StreamPiece,
|
||||
]
|
||||
assert pieces[2] == RetryPiece(2, 4)
|
||||
assert pieces[3] == StreamPiece("content", "forced answer")
|
||||
assert len(llm.requests) == 4 # 2 tool rounds + the final + its retry
|
||||
# The forced final (and its retry) carry no tools, whatever is left.
|
||||
assert llm.requests[2][1] is None
|
||||
assert llm.requests[3][1] is None
|
||||
# …and the restart is byte-identical.
|
||||
assert llm.requests[2][0] == llm.requests[3][0]
|
||||
assert holder.tool_calls == 2
|
||||
|
||||
|
||||
def test_zero_retries_is_one_plain_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The kill-switch path (``llm_retries=0``): a dead round raises
|
||||
immediately — one request, no RetryPiece, no sleep (pre-phase-67
|
||||
behavior)."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM([([], LLMError("connection refused"))])
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
|
||||
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
with pytest.raises(LLMError, match="connection refused"):
|
||||
async for piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
cast("Session", None),
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=_settings(llm_retries=0),
|
||||
holder=holder,
|
||||
):
|
||||
out.append(piece)
|
||||
return out
|
||||
|
||||
out = asyncio.run(drain())
|
||||
assert out == [] # nothing streamed, no RetryPiece
|
||||
assert len(llm.requests) == 1
|
||||
assert sleeps == []
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
|
||||
|
||||
def test_abandon_mid_retry_sleep_leaks_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Consumer abandon while a retried round is parked in the pre-retry
|
||||
sleep (client disconnect): the driving task is cancelled cleanly, the
|
||||
production teardown ``aclose()`` on ``run_agent`` does not raise, the
|
||||
inner attempt's stream was torn down, and the retry never starts."""
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def parking_sleep(seconds: float) -> None:
|
||||
entered.set()
|
||||
await asyncio.Event().wait() # park until the abandon arrives
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", parking_sleep)
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
gen = run_agent(
|
||||
cast("LLMClient", llm),
|
||||
cast("Session", None),
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=_settings(),
|
||||
holder=holder,
|
||||
)
|
||||
|
||||
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
return [p async for p in gen]
|
||||
|
||||
task = asyncio.ensure_future(consumer())
|
||||
await entered.wait() # the round's retry is parked in the sleep
|
||||
assert not task.done()
|
||||
task.cancel() # client disconnect: the driving task is cancelled
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
# Production teardown (phase 48 pattern): must not raise. ``run_agent``
|
||||
# is an async generator despite its AsyncIterator annotation.
|
||||
await cast(
|
||||
"AsyncGenerator[StreamPiece | ToolCallPiece | RetryPiece, None]", gen
|
||||
).aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(llm.requests) == 1 # the retry never started
|
||||
assert llm.closed == [0] # attempt 1's inner stream was torn down
|
||||
assert holder.read_docs == [] and holder.tool_calls == 0
|
||||
|
||||
|
||||
def test_retries_are_invisible_to_the_round_cap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A failing-then-succeeding round consumes ONE round: with a cap of
|
||||
2, the retried first round and the second tool round fill the cap —
|
||||
the forced final follows the SECOND call, and the log lines read
|
||||
round=1/2 and round=2/2."""
|
||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||
holder = AgentHolder()
|
||||
llm = FailingLLM(
|
||||
[
|
||||
([], LLMError("down")),
|
||||
([ToolCallPiece(id="call_1", name="list_documents", arguments={})], None),
|
||||
([ToolCallPiece(id="call_2", name="list_documents", arguments={})], None),
|
||||
([StreamPiece("content", "forced answer")], None),
|
||||
]
|
||||
)
|
||||
with caplog.at_level(logging.INFO, logger="app.agent"):
|
||||
pieces = asyncio.run(_run(llm, holder, _settings(agent_max_rounds=2)))
|
||||
assert [type(p) for p in pieces] == [
|
||||
RetryPiece,
|
||||
ToolCallPiece,
|
||||
ToolCallPiece,
|
||||
StreamPiece,
|
||||
]
|
||||
assert len(llm.requests) == 4 # 2 (round 1 + its retry) + 1 + the forced final
|
||||
assert llm.requests[3][1] is None # the forced final, after round 2
|
||||
assert holder.tool_calls == 2
|
||||
msgs = [r.getMessage() for r in caplog.records]
|
||||
assert "agent tool=list_documents args={} round=1/2" in msgs
|
||||
assert "agent tool=list_documents args={} round=2/2" in msgs
|
||||
assert any("round cap reached (rounds=2)" in m for m in msgs)
|
||||
|
||||
|
||||
# ---------- prompts: <tools> section (HIGH only) ----------
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,41 @@ def test_max_output_tokens_env_override(monkeypatch) -> None:
|
||||
assert s.max_output_tokens == 1234
|
||||
|
||||
|
||||
def test_llm_retry_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Phase 67: a failed LLM request is retried by default — 3 retries
|
||||
with a flat 5 s delay (the TODO-locked values, no backoff)."""
|
||||
monkeypatch.delenv("BOR_LLM_RETRIES", raising=False)
|
||||
monkeypatch.delenv("BOR_LLM_RETRY_DELAY", raising=False)
|
||||
s = _settings()
|
||||
assert s.llm_retries == 3
|
||||
assert s.llm_retry_delay == 5.0
|
||||
|
||||
|
||||
def test_llm_retry_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``BOR_LLM_RETRIES`` / ``BOR_LLM_RETRY_DELAY`` override the defaults;
|
||||
``0`` retries is the no-retry kill switch (pre-phase-67 behavior)."""
|
||||
monkeypatch.setenv("BOR_LLM_RETRIES", "0")
|
||||
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "1.5")
|
||||
s = _settings()
|
||||
assert s.llm_retries == 0
|
||||
assert s.llm_retry_delay == 1.5
|
||||
|
||||
|
||||
def test_llm_retries_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``0`` is the kill switch — a negative value is a typo, so the
|
||||
validator fails loudly at startup (the ``agent_max_rounds`` pattern)."""
|
||||
monkeypatch.setenv("BOR_LLM_RETRIES", "-1")
|
||||
with pytest.raises(ValidationError, match="llm_retries"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_llm_retry_delay_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A negative delay is a typo — fail loudly at startup."""
|
||||
monkeypatch.setenv("BOR_LLM_RETRY_DELAY", "-0.5")
|
||||
with pytest.raises(ValidationError, match="llm_retry_delay"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_agent_max_rounds_default_and_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Phase 45: the per-tool budgets are gone — ``BOR_AGENT_MAX_ROUNDS``
|
||||
(default 10) is the single agent-loop knob; ``0`` is the no-tools
|
||||
|
||||
@@ -605,3 +605,56 @@ def test_index_messages_comment_documents_the_meta_actions() -> None:
|
||||
assert "every visitor" in comment.lower() or "everyone" in comment.lower(), (
|
||||
"Retry is documented as available to all visitors"
|
||||
)
|
||||
|
||||
|
||||
# ---------- llm retry status (phase 67, task 04) ----------
|
||||
|
||||
|
||||
def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
|
||||
"""Phase 67 (owner-locked A4, task 02's contract): a `retry` SSE
|
||||
frame (the server restarted the LLM request before its first piece
|
||||
— locked A2) is a first-class branch in runTurn's handler, ordered
|
||||
BETWEEN the `tool` and `delta` branches. It clears the 120s guard
|
||||
(a frame arrived), resolves n/N from the frame, and writes the
|
||||
owner-locked copy literal onto the EXISTING channels only — the
|
||||
#send-status live region + the typing-indicator aria-label. No DOM
|
||||
of its own: no addMessage, no appendToolLine, no error banner, no
|
||||
UI-state change. The gate covers BOTH live states: a later agent
|
||||
round may restart while the UI already streams."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
retry_idx = js.find('ev.type === "retry"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
assert -1 < tool_idx < retry_idx < delta_idx, (
|
||||
"the turn handler must branch on retry frames, between tool and delta"
|
||||
)
|
||||
assert js.count('ev.type === "retry"') == 1, "exactly one retry branch"
|
||||
branch = js[retry_idx:delta_idx]
|
||||
assert "clearTurnTimeout()" in branch, "a frame arrived — the 120s guard clears"
|
||||
assert "Number(ev.attempt)" in branch, "n = the attempt about to be tried"
|
||||
assert "Number(ev.max_attempts)" in branch, "N = the configured total"
|
||||
assert (
|
||||
"`Communication interrupted — retrying (${attempt} of ${max})…`" in branch
|
||||
), "the owner-locked copy literal (A4)"
|
||||
assert "sendStatus.textContent = retryStatus" in branch, (
|
||||
"the existing #send-status live region carries the status"
|
||||
)
|
||||
assert "#typing-indicator .bubble" in branch, ("the typing-indicator is reused")
|
||||
assert 'setAttribute("aria-label", retryStatus)' in branch
|
||||
assert "UI_STATE.thinking" in branch and "UI_STATE.streaming" in branch, (
|
||||
"the gate covers BOTH live states (a later round may restart mid-stream)"
|
||||
)
|
||||
for forbidden in ("addMessage", "appendToolLine", "showErrorBanner", "setUiState"):
|
||||
assert forbidden not in branch, f"transient status only — no {forbidden}"
|
||||
|
||||
|
||||
def test_header_inventory_documents_the_retry_frame() -> None:
|
||||
"""The app.js file-header doc comment inventories every SSE frame
|
||||
type (house convention); phase 67 adds the `retry` frame there, with
|
||||
the owner-locked copy quoted, so the A4 literal has exactly two
|
||||
homes: the header doc and the handler branch."""
|
||||
js = _js()
|
||||
header = js[: js.find("import {")]
|
||||
assert "`retry`" in header, "the header must list the retry frame"
|
||||
assert "Communication interrupted — retrying" in header
|
||||
assert header.count("Communication interrupted — retrying") == 1
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -21,8 +22,10 @@ from app.rag.llm import (
|
||||
EmbeddingError,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
RetryPiece,
|
||||
StreamPiece,
|
||||
ToolCallPiece,
|
||||
chat_stream_retried,
|
||||
)
|
||||
|
||||
|
||||
@@ -800,3 +803,274 @@ def test_chat_whitespace_only_content_raises_llm_error() -> None:
|
||||
llm, _ = _make_chat_client(_FakeCompletion(" \n\t "))
|
||||
with pytest.raises(LLMError, match="empty content"):
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
|
||||
|
||||
# ---------- chat_stream_retried (phase 67, task 01) ----------
|
||||
|
||||
_RETRY_MSGS: list[dict[str, str]] = [{"role": "user", "content": "q"}]
|
||||
|
||||
|
||||
class _ScriptedClient(LLMClient):
|
||||
"""An LLMClient whose ``chat_stream`` is scripted per attempt — no
|
||||
endpoint. ``attempts`` scripts the Nth call: ``(pieces, error)`` — the
|
||||
stream yields *pieces*, then raises *error* if not None (an error after
|
||||
zero pieces = "the endpoint died before the first token"; after some
|
||||
pieces = a mid-stream drop). Records every request's messages/tools
|
||||
and every attempt's stream teardown (the phase-48 close analog)."""
|
||||
|
||||
def __init__(
|
||||
self, attempts: list[tuple[list[StreamPiece | ToolCallPiece], Exception | None]]
|
||||
) -> None:
|
||||
super().__init__(_settings())
|
||||
self.attempts = list(attempts)
|
||||
self.request_args: list[
|
||||
tuple[list[dict[str, str]], list[dict[str, Any]] | None]
|
||||
] = []
|
||||
#: Indices of attempts whose stream teardown has run.
|
||||
self.closed: list[int] = []
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||
index = len(self.request_args)
|
||||
pieces, error = (
|
||||
self.attempts[index]
|
||||
if index < len(self.attempts)
|
||||
else ([], LLMError("script exhausted"))
|
||||
)
|
||||
self.request_args.append(
|
||||
(list(messages), list(tools) if tools is not None else None)
|
||||
)
|
||||
return self._attempt(index, pieces, error)
|
||||
|
||||
async def _attempt(
|
||||
self, index: int, pieces: list[StreamPiece | ToolCallPiece],
|
||||
error: Exception | None,
|
||||
) -> AsyncGenerator[StreamPiece | ToolCallPiece, None]:
|
||||
try:
|
||||
for piece in pieces:
|
||||
yield piece
|
||||
if error is not None:
|
||||
raise error
|
||||
finally:
|
||||
self.closed.append(index)
|
||||
|
||||
|
||||
def _record_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
||||
"""Monkeypatch ``asyncio.sleep`` (what ``chat_stream_retried`` awaits
|
||||
for the flat pre-retry delay) and record every awaited delay."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
return sleeps
|
||||
|
||||
|
||||
def _collect_retried(
|
||||
client: _ScriptedClient,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
retries: int,
|
||||
delay: float,
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
async def run() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
return [
|
||||
p
|
||||
async for p in chat_stream_retried(
|
||||
client, messages, tools=tools, retries=retries, delay=delay
|
||||
)
|
||||
]
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_retried_retries_a_dead_attempt_before_the_first_piece(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Failure on attempt 1, success on attempt 2 → [RetryPiece(2, N)]
|
||||
(the attempt about to be tried, 1-based) then the answer pieces; the
|
||||
request is restarted byte-identical and the flat delay is awaited
|
||||
exactly once."""
|
||||
answer: list[StreamPiece | ToolCallPiece] = [
|
||||
StreamPiece("content", "A "),
|
||||
StreamPiece("content", "B"),
|
||||
]
|
||||
client = _ScriptedClient([([], LLMError("connection refused")), (answer, None)])
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
pieces = _collect_retried(client, _RETRY_MSGS, retries=3, delay=2.5)
|
||||
assert pieces == [RetryPiece(2, 4), *answer]
|
||||
assert len(client.request_args) == 2
|
||||
# The restart is byte-identical: same messages, same (absent) tools.
|
||||
assert client.request_args[0] == client.request_args[1]
|
||||
assert client.request_args[0][1] is None
|
||||
assert sleeps == [2.5]
|
||||
|
||||
|
||||
def test_retried_exhaustion_yields_all_retries_then_raises(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""retries=2 → 3 attempts; each pre-first-piece failure yields a
|
||||
RetryPiece naming the attempt about to be tried (attempts 2 and 3 of
|
||||
3), the final failure raises the terminal LLMError, and no sleep
|
||||
follows the last attempt."""
|
||||
client = _ScriptedClient(
|
||||
[([], LLMError("down 1")), ([], LLMError("down 2")), ([], LLMError("down 3"))]
|
||||
)
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
|
||||
async def run() -> list[RetryPiece]:
|
||||
out: list[RetryPiece] = []
|
||||
with pytest.raises(LLMError, match="down 3"):
|
||||
async for p in chat_stream_retried(
|
||||
client, _RETRY_MSGS, retries=2, delay=0.5
|
||||
):
|
||||
assert isinstance(p, RetryPiece)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
out = asyncio.run(run())
|
||||
assert out == [RetryPiece(2, 3), RetryPiece(3, 3)]
|
||||
assert len(client.request_args) == 3
|
||||
assert sleeps == [0.5, 0.5]
|
||||
|
||||
|
||||
def test_retried_failure_after_first_piece_is_terminal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Locked A2: a piece has already flowed → the LLMError is re-raised
|
||||
immediately — no RetryPiece, no sleep, no second call (a partial
|
||||
answer is never redone)."""
|
||||
client = _ScriptedClient(
|
||||
[([StreamPiece("content", "partial ")], LLMError("mid-stream drop"))],
|
||||
)
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
|
||||
async def run() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece] = []
|
||||
with pytest.raises(LLMError, match="mid-stream drop"):
|
||||
async for p in chat_stream_retried(
|
||||
client, _RETRY_MSGS, retries=3, delay=5.0
|
||||
):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
out = asyncio.run(run())
|
||||
assert out == [StreamPiece("content", "partial ")]
|
||||
assert not any(isinstance(p, RetryPiece) for p in out)
|
||||
assert len(client.request_args) == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_retried_zero_retries_is_one_attempt_no_retry_pieces(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The kill-switch path (retries=0): exactly one attempt, the error
|
||||
propagates, no RetryPiece, no sleep — the pre-phase-67 behavior."""
|
||||
client = _ScriptedClient([([], LLMError("connection refused"))])
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
with pytest.raises(LLMError, match="connection refused"):
|
||||
_collect_retried(client, _RETRY_MSGS, retries=0, delay=5.0)
|
||||
assert len(client.request_args) == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_retried_healthy_stream_is_untouched(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No failure → exactly one attempt, every piece kind (thinking / tool
|
||||
call / content) passes through unchanged, no RetryPiece, no sleep —
|
||||
a healthy turn is byte-identical to the plain chat_stream."""
|
||||
answer = [
|
||||
StreamPiece("thinking", "hmm"),
|
||||
ToolCallPiece(id="call_1", name="list_documents", arguments={}),
|
||||
StreamPiece("content", "Talos."),
|
||||
]
|
||||
client = _ScriptedClient([(answer, None)])
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "list_documents", "parameters": {}},
|
||||
}
|
||||
]
|
||||
pieces = _collect_retried(
|
||||
client, _RETRY_MSGS, tools=tools, retries=3, delay=5.0
|
||||
)
|
||||
assert pieces == answer
|
||||
assert client.request_args == [(_RETRY_MSGS, tools)]
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_retried_zero_delay_still_notifies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The e2e fast path (BOR_LLM_RETRY_DELAY=0): the RetryPiece is still
|
||||
emitted and the (zero) sleep is still awaited."""
|
||||
client = _ScriptedClient(
|
||||
[([], LLMError("down")), ([StreamPiece("content", "ok")], None)]
|
||||
)
|
||||
sleeps = _record_sleeps(monkeypatch)
|
||||
pieces = _collect_retried(client, _RETRY_MSGS, retries=1, delay=0)
|
||||
assert pieces == [RetryPiece(2, 2), StreamPiece("content", "ok")]
|
||||
assert sleeps == [0]
|
||||
|
||||
|
||||
def test_retried_abandon_mid_attempt_closes_the_attempt_stream() -> None:
|
||||
"""Consumer abandon at a mid-attempt piece (the stop-generation path,
|
||||
phase 48): no exception leaks and the attempt's stream is torn down
|
||||
through the wrapper's explicit close."""
|
||||
client = _ScriptedClient(
|
||||
[([StreamPiece("content", "A "), StreamPiece("content", "B ")], None)]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
gen = chat_stream_retried(client, _RETRY_MSGS, retries=3, delay=1.0)
|
||||
first = await gen.__anext__()
|
||||
assert first == StreamPiece("content", "A ")
|
||||
await gen.aclose() # the consumer stops after the first piece
|
||||
|
||||
asyncio.run(run())
|
||||
assert client.closed == [0] # attempt 1's stream was closed
|
||||
assert len(client.request_args) == 1 # no second attempt
|
||||
|
||||
|
||||
def test_retried_abandon_during_retry_sleep_leaks_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Consumer abandon while the generator is parked in the pre-retry
|
||||
sleep (client disconnect): the driving task is cancelled cleanly,
|
||||
the phase-48 teardown ``aclose()`` on the generator does not raise,
|
||||
and the retry never starts."""
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def parking_sleep(seconds: float) -> None:
|
||||
entered.set()
|
||||
await asyncio.Event().wait() # park until the abandon arrives
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", parking_sleep)
|
||||
client = _ScriptedClient(
|
||||
[([], LLMError("endpoint down")), ([StreamPiece("content", "never")], None)]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
gen = chat_stream_retried(client, _RETRY_MSGS, retries=3, delay=1.5)
|
||||
|
||||
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]:
|
||||
return [p async for p in gen]
|
||||
|
||||
task = asyncio.ensure_future(consumer())
|
||||
await entered.wait() # the generator is inside the pre-retry sleep
|
||||
assert not task.done()
|
||||
task.cancel() # client disconnect: the driving task is cancelled
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
# Production teardown (phase 48 pattern): the endpoint's finally
|
||||
# closes the stream generator — must not raise.
|
||||
await gen.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(client.request_args) == 1 # the retry never started
|
||||
assert client.closed == [0] # attempt 1's stream was torn down
|
||||
|
||||
@@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from app.api.chat import sse_event
|
||||
from app.schemas import ChatErrorEvent, ChatThinkingEvent, ChatToolEvent
|
||||
from app.schemas import (
|
||||
ChatErrorEvent,
|
||||
ChatRetryEvent,
|
||||
ChatThinkingEvent,
|
||||
ChatToolEvent,
|
||||
)
|
||||
|
||||
|
||||
def _payload(frame: str) -> dict:
|
||||
@@ -100,3 +105,23 @@ def test_tool_event_shape_is_type_name_argument_only() -> None:
|
||||
dumped = ChatToolEvent(name="read_document", argument="S/p.md").model_dump()
|
||||
assert set(dumped.keys()) == {"type", "name", "argument"}
|
||||
assert dumped["type"] == "tool" # default — call sites never spell it out
|
||||
|
||||
|
||||
def test_retry_frame_serializes_exactly() -> None:
|
||||
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
|
||||
attempt: int, max_attempts: int}`` — a transient status the client's
|
||||
readSSE handler branches on ("Communication interrupted — retrying
|
||||
(n of N)…"), never an error. ``attempt`` is the attempt the server is
|
||||
about to try next (1-based); the contract the frontend branch and the
|
||||
E2E suite key off, locked here ahead of the JS implementation."""
|
||||
frame = sse_event(ChatRetryEvent(attempt=2, max_attempts=4).model_dump())
|
||||
assert frame == 'data: {"type": "retry", "attempt": 2, "max_attempts": 4}\n\n'
|
||||
assert _payload(frame) == {"type": "retry", "attempt": 2, "max_attempts": 4}
|
||||
|
||||
|
||||
def test_retry_event_shape_is_type_attempt_max_attempts_only() -> None:
|
||||
dumped = ChatRetryEvent(attempt=2, max_attempts=4).model_dump()
|
||||
assert set(dumped.keys()) == {"type", "attempt", "max_attempts"}
|
||||
assert dumped["type"] == "retry" # default — call sites never spell it out
|
||||
assert isinstance(dumped["attempt"], int)
|
||||
assert isinstance(dumped["max_attempts"], int)
|
||||
|
||||
Reference in New Issue
Block a user