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) <noreply@anthropic.com>
This commit is contained in:
David SF
2026-07-16 23:03:21 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 67f8d870a5
commit e4839c654b
2 changed files with 21 additions and 0 deletions
@@ -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
+14
View File
@@ -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."""