From e4839c654b855ce52d09348930e6858c165a1e74 Mon Sep 17 00:00:00 2001 From: David SF <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:03:21 -0500 Subject: [PATCH] Keep already-empty ModelRequests in LimitWarner._strip_old_warnings (#311) LimitWarner rebuilds history each request, dropping any ModelRequest left with no parts after warning markers are stripped. An already-empty ModelRequest -- which pydantic-ai's empty-response retry appends when a model returns an empty response against a non-optional output type -- has no parts to begin with, so it was dropped too. That left history ending on a ModelResponse, and the next request failed the _agent_graph precondition with `UserError: Processed history must end with a ModelRequest`, killing the run. Keep a request that was already empty; only drop one that became empty because every part was a warning marker. Regression test covers the empty-response-retry tail. Co-authored-by: Claude Opus 4.8 (1M context) --- pydantic_ai_harness/compaction/_limit_warner.py | 7 +++++++ tests/compaction/test_compaction.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/pydantic_ai_harness/compaction/_limit_warner.py b/pydantic_ai_harness/compaction/_limit_warner.py index 7dee099..9eb5e0d 100644 --- a/pydantic_ai_harness/compaction/_limit_warner.py +++ b/pydantic_ai_harness/compaction/_limit_warner.py @@ -121,6 +121,13 @@ class LimitWarner(AbstractCapability[AgentDepsT]): if not isinstance(msg, ModelRequest): cleaned.append(msg) continue + # An already-empty request has no warnings to strip; keep it. pydantic-ai's + # empty-response retry appends a `ModelRequest` with no parts, and dropping it + # would leave history ending on a `ModelResponse`, which fails the next + # request's "must end with a ModelRequest" precondition. + if not msg.parts: + cleaned.append(msg) + continue parts = [p for p in msg.parts if not self._is_marker_part(p)] if not parts: continue diff --git a/tests/compaction/test_compaction.py b/tests/compaction/test_compaction.py index e8a3c86..cd3d80b 100644 --- a/tests/compaction/test_compaction.py +++ b/tests/compaction/test_compaction.py @@ -807,6 +807,20 @@ class TestLimitWarnerEdgeCases: assert isinstance(first, ModelRequest) assert len(first.parts) == 1 + @pytest.mark.anyio + async def test_keeps_already_empty_request(self): + """An already-empty ModelRequest must survive stripping so history still ends with a + ModelRequest. pydantic-ai's empty-response retry appends an empty ModelRequest; + dropping it left history ending on a ModelResponse and crashed the next request with + `Processed history must end with a ModelRequest`.""" + lw = LimitWarner(max_iterations=100) + messages: list[ModelMessage] = [_user('real'), ModelResponse(parts=[]), ModelRequest(parts=[])] + rc = _make_request_context(messages) + ctx = _make_ctx(requests=5) + result = await lw.before_model_request(ctx, rc) + assert len(result.messages) == 3 + assert isinstance(result.messages[-1], ModelRequest) + @pytest.mark.anyio async def test_context_warning_below_threshold(self): """Context window should not warn when below threshold."""