Repository navigation
Fix durable agent retries replaying the model's rejected answers instead of asking again - #74720
Merged
Merged
Conversation
…ected answers With durable=True, a run that pydantic-ai fails with UnexpectedModelBehavior (a tool's ModelRetry or output validation past the retry limit) replayed every recorded answer into the same error on each Airflow retry, without a live model call. DurableRun.fail now takes model_at_fault: when no step raised the error, the retry replays the steps before the model's last request and asks the model again from there. A previous attempt's divergence point also stays until an attempt records over it, so a re-asked request that fails is not replayed from the older attempt.
vatsrahul1001
approved these changes
Oct 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With
durable=True, an agent run that pydantic-ai fails withUnexpectedModelBehaviorcould never succeed on a retry. That error is how pydantic-ai gives up when the model keeps giving answers a tool rejects withModelRetry(SQLToolsetdoes this for a query outsideallowed_tablesor one it cannot parse), or output that fails validation, past the retry limit. The rejected answers are recorded in the journal like any other step, so every Airflow retry replayed them into the identical error without a single live model call. Withoutdurable, the same retry would have asked the model again.The journal decides where a retry stops replaying by matching the run's exception against the steps that raised it. This error is raised by pydantic-ai after the last recorded step, so nothing matched and the retry trusted everything.
DurableRun.failnow takesmodel_at_fault, whichAirflowDurabilitysets forUnexpectedModelBehavior: when no step raised the error, the retry replays the steps before the model's last request and asks the model again from there.Before: the retry replays all four recorded steps and fails the same way.
After: the retry replays the first request and its rejected query, asks the model again at position 2, and succeeds.
Both runs are the same Dag in
airflow standalone: a plain@taskrunning a pydantic-ai agent withAirflowDurability, the realSQLToolsetagainst SQLite, and a scripted model that queries a table outsideallowed_tableson try 1 and the right table whenever it is asked live afterwards.Design rationale
Why not treat every unattributed failure this way. A cancelled or killed run (
on_kill,execution_timeout) also reachesfailwithout a step to blame, and there replaying everything recorded and running the interrupted step live is right. Re-asking the model would pay for a request the run already has.UsageLimitExceededis deterministic, so a live re-ask would only fail again at a cost. OnlyUnexpectedModelBehaviorsays the model's own answers are the problem, so the journal takes the flag from the adapter rather than guessing, and stays framework-neutral.Why the model's last request rather than the first rejected one. The earlier rejected answers stay in the history and count against pydantic-ai's retry limit, so each Airflow retry gives the model one more answer with the rejections it already got as context. Rewinding further would restore the full budget but also re-run every tool call those earlier answers made.
A step that raised still decides. When the error propagates out of a step (an agent a tool runs giving up on its own model), that step's position wins and the outer model's answer keeps replaying, as before this change.
A previous attempt's divergence point now holds until an attempt records over it. Otherwise, if the re-asked request itself fails (a 429), the next retry would replay the older attempt's rejected answer at that position, since the request is identical and its fingerprint matches. The same rule closes the general case of a step that raised at the point an earlier attempt diverged.
Gotchas
Tool calls the model's last answer made run again if the model asks for them again, including calls that succeeded alongside the rejected one.
durable_execution.rstnow says so, both where the retry behaviour is described and in the side-effects section.{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.