A Pydantic AI Regression Inside a 47-File Fix

A targeted historical replay traced incomplete streaming responses to one drain loop removed during a 47-file cleanup.

Six Pydantic AI release findings, with five expected changes and one highlighted regression.

Regression Case File | Open-source historical replay

One removed drain loop in Pydantic AI v1.92.0 could leave an agent run with an incomplete or empty response. Tool calls could disappear. Token usage could read zero. With some providers, the caller received less than the model produced without an error explaining why.

The affected path was specific. A caller had to supply an event_stream_handler, then return from that handler before consuming the full stream. The default path without a handler still drained the stream correctly.

Pydantic AI is the Pydantic team’s Python framework for building AI agents. In a framework like this, the returned response is the record of what the agent did. Losing the end of that response changes the result of the run.

We later selected the release that introduced the regression and ran a targeted historical replay. Regression Guard analyzed the change from v1.91.0 to v1.92.0. It produced six findings, classified one as a Regression, and that finding described the real defect.

Early did not participate in the original release. Pydantic did not sponsor or participate in the replay, and this case does not present Pydantic as an Early customer.

The Failure

The public report, Pydantic AI issue #5769, documented incomplete and often empty results when a streaming handler did not consume every event. The report covered versions v1.92.0 through v1.105.0 and described the behavior as provider-agnostic.

The visible symptom varied. A real provider could return a truncated result with missing tool calls and under-reported usage. In our offline reproduction, the empty response triggered Pydantic AI’s retry logic and ended with UnexpectedModelBehavior after the output retries were exhausted.

Both outcomes came from the same state: the stream ended from the caller’s perspective before the complete ModelResponse had been assembled.

The Timeline

The culprit merged on May 8, 2026 and became the v1.92.0 tag commit. The release was published 21 minutes later. A user reported the problem on June 3, 26 days after release. The fix merged the next day and shipped in v1.106.0 on June 5.

That gives the case three different measurements, and each means something different:

  • 26 days from release to the public report
  • 28 days from release to the fixed release
  • 14 affected releases, v1.92.0 through v1.105.0

The regression shipped on May 8, was reported 26 days later, and reached a fixed release after fourteen affected releases

The release timeline separates report timing, fixed-release timing, and the fourteen affected releases.

The Culprit Change

The change came from PR #5313, “Clean up streaming responses on cancellation”. It was a legitimate fix for cancellation behavior, spread across 47 files. The culprit commit passed all 78 of the project’s executed CI checks.

Inside that cleanup, one loop was removed from the normal completion path. Its job was to keep consuming events until the stream was drained. Removing it changed completion behavior when a supplied handler returned early.

The sequence was straightforward:

  1. The model produced a stream of events.
  2. The supplied handler consumed only part of that stream.
  3. The handler returned.
  4. The remaining events were no longer drained.
  5. The stored response remained incomplete.
  6. The partial response became the result used by the caller.

Before v1.92.0 the remaining stream was drained after a handler returned, while v1.92.0 could preserve an incomplete response

The behavior changed when a handler returned before consuming the full stream.

The same contributor later opened PR #5771. The fix restored draining after the handler returned and added 92 lines to tests/test_streaming.py. This is the useful part of the case. A change can solve the problem named in its pull request and still alter an adjacent behavioral contract.

How We Evaluated It

This was a targeted replay, not a blind trial. We knew which historical release contained the regression before selecting the comparison. Regression Guard did not receive that expected answer.

The run compared the v1.91.0 source tag with the v1.92.0 destination tag. Nothing in the project configuration mentioned streaming, event_stream_handler, usage accounting, or incomplete responses.

Per Early’s input scoping and access controls, the analysis could read only the repository contents between those tags and had no web, GitHub, retrieval, or other external access.

There is an important methodology detail. The repository copy contained later history on main, including the eventual fix. That history was outside the run’s base-to-target input scope. The claim that the fix was unavailable rests on Early’s documented input controls and is consistent with the run metadata and the finding, which cited only release-time code.

What Regression Guard Found

The comparison contained six commits. Regression Guard returned six findings:

Verdict Count Review result
Regression 1 The real streaming defect
Expected 5 Classified as expected changes

The Regression finding appeared first and was the only finding carrying a confidence score. There were no other Regression findings to sort through. We independently verified that its reasoning matched the later upstream evidence.

The finding identified the exact deleted drain loop, distinguished the normal path from the exception path, and traced a reachable caller through event_stream_handler. It also explained how an unconsumed stream could leave get() and usage() reading a truncated ModelResponse.

The user-visible finding was:

Regression Guard finding

“Removed forced drain truncates usage on early exit”

Regression | High severity | Confidence 8/10

The finding as it appeared in Early:

Early Regression Guard finding titled Removed forced drain truncates usage on early exit, classified as a Regression with confidence 8 out of 10

The actual Regression Guard finding from the Early product.

It did not identify the originating pull request or connect the result to the later public issue. Those links were added during human verification. We also did not independently verify every Expected classification, so the precision statement here is deliberately narrow: the run produced one Regression finding, and that one finding was a true positive.

Verification and Limits

Reproduction Results

Regression Guard’s finding included a verification procedure: run the agent once normally, run it again with a handler that stops after the first event, then compare the output and token usage.

We executed an offline version with Pydantic AI’s deterministic TestModel. It needed no API key and ran against the published PyPI packages.

VersionResultObserved behavior
v1.91.0PassOutput and usage matched
v1.92.0FailEmpty response triggered retry exhaustion
v1.106.0PassOutput and usage matched

The failing run followed the same handler and graph path named in the finding. The result also matched the later upstream report and the fix that restored stream draining.

This reproduction does not prove that the project’s checks were weak. It establishes a narrower fact. The culprit commit passed 78 executed CI checks, while a small test of the early-returning handler condition failed on the released package.

Honest Limits

The replay was conducted after the incident, and the release window was selected because it contained a known regression. That makes it a useful case study, not an unbiased estimate of detection performance across arbitrary releases.

Regression Guard’s finding also led with truncated usage accounting. The more important customer-visible symptom was an incomplete or empty response. Its mechanism was correct, but its headline undersold the impact. The finding also needed the explicit qualification that the default path without a handler still drained the stream.

The result supports one precise conclusion: in this targeted historical replay, Regression Guard identified the defective behavior from the selected release context, and its proposed verification failed on the affected version and passed before and after it. It does not establish that every regression will be detected or that Early prevented this release.

What This Case Shows

The original cleanup addressed a real cancellation problem. The regression sat beside that purpose, in the completion behavior that followed a handler’s early return. Review and CI evaluated the change without a test for that exact condition.

Regression Guard adds a different question to release review: which existing behaviors changed when the release candidate moved away from its production baseline? In this case, that question surfaced one removed loop inside a 47-file cleanup and gave the team a concrete experiment to run.

The strongest part of the result is not the label. It is the chain of evidence. The code-level finding, the offline reproduction, and the later upstream issue and fix all point to the same mechanism.

Reproduce the Failure and Verify the Finding

The public earlyai/test-pydantic-ai repository contains the release history, Early-generated investigation prompt, and exact offline reproduction used for this case.

With the repository, you can:

  1. Inspect the code and Git history between v1.91.0 and v1.92.0.
  2. Give Claude Code or Codex the supplied Early-generated prompt and require it to verify, refute, or correct the finding with file, line, commit, and tag evidence.
  3. Run the offline script with v1.91.0, v1.92.0, and v1.106.0 to reproduce the pass/fail/pass sequence without a model API key.

This independently verifies Early’s finding and reproduces the defect and fix. It does not rerun Regression Guard or recreate its proprietary detection process. Read the technical replay guide for setup instructions, expected results, and primary-source links.

Sources and Method

The primary public sources are issue #5769, the introducing release comparison, the introducing pull request, the fixing pull request, the fixed release comparison, and the follow-up cancellation-semantics tracker.

The historical replay ran on August 14, 2026. Human verification followed the product output and compared it with the repository history, the executed reproduction, and the later upstream record. Internal environment identifiers and non-public calibration details are intentionally excluded.

Related articles

Which Change Caused the Incident?The better question is whether we could have caught the regression before production.Reviewing the Diff Was Never the Hard PartAI review made the local problem smaller. It made the systemic one bigger.You Did Not Touch It. It Broke Anyway.The bottleneck in software delivery has shifted from writing code to verifying what changed.

Remember a regression your team already understands?