This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 190befa57c1 Ask the model again when a durable agent's retry would
replay its rejected answers (#74720)
190befa57c1 is described below
commit 190befa57c106a62a8101f240a0ebd3e2429f336
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 12:36:51 2026 +0100
Ask the model again when a durable agent's retry would replay its rejected
answers (#74720)
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.
---
providers/common/ai/docs/durable_execution.rst | 17 +++-
.../providers/common/ai/durable/capability.py | 13 ++-
.../airflow/providers/common/ai/durable/journal.py | 63 ++++++++++--
.../unit/common/ai/durable/test_capability.py | 106 ++++++++++++++++++++-
.../tests/unit/common/ai/durable/test_journal.py | 90 ++++++++++++++++-
5 files changed, 277 insertions(+), 12 deletions(-)
diff --git a/providers/common/ai/docs/durable_execution.rst
b/providers/common/ai/docs/durable_execution.rst
index 59d4aaa7408..5c670045998 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -151,6 +151,20 @@ other capabilities. Tool calls already running alongside
the failed one still re
step that raised and that the run recovered from, such as a tool error the
model was
told about, does not stop the steps after it from replaying.
+A run can also fail on the model's answers rather than in a step: pydantic-ai
raises
+``UnexpectedModelBehavior`` when the model keeps giving answers a tool rejects
with
+``ModelRetry``, or output that fails validation, past its retry limit. Those
answers are
+recorded, so replaying them would only reach the same error. The retry instead
replays
+the steps before the model's last request and asks the model again from there.
The
+earlier rejected answers still count against the retry limit, so each Airflow
retry gives
+the model one more answer, with the rejections it already got in its history.
Tool calls
+the last answer made run again if the model asks for them again, including
calls that
+succeeded alongside the one that was rejected. The task log names the step:
+
+.. code-block:: text
+
+ Durable: the run took a different path from the previous attempt; this
step and every step after it run again position=2 step=analyst__model.request
reason=the model's answers from this step on failed the previous attempt, so it
is asked again
+
Some steps have no request to fingerprint: another capability's
``@durable_operation``, or tool discovery. Such a step replays on its name,
and only when
the step before it also replayed.
@@ -236,7 +250,8 @@ Durable execution records what a tool returned. A replayed
tool's code does not
side effect it had (a file written, a message sent) is not repeated.
A tool that fails *before* its result is recorded runs again on retry, so a
tool that
sent an email and then raised sends it again. The same holds when the worker
dies
-between a tool's side effect and the write of its result. Make tools with side
effects
+between a tool's side effect and the write of its result, and for the tool
calls of a
+model answer that the retry asks for again after the model ran out of retries.
Make tools with side effects
idempotent: check whether the work already happened, or let a database
constraint
reject the duplicate.
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
b/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
index 3a420b17adf..cabbb425336 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
@@ -37,7 +37,13 @@ from pydantic_ai.durable_exec import (
ToolsetCallToolId,
ToolsetValidateToolArgumentsId,
)
-from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry,
ToolFailed
+from pydantic_ai.exceptions import (
+ ApprovalRequired,
+ CallDeferred,
+ ModelRetry,
+ ToolFailed,
+ UnexpectedModelBehavior,
+)
from pydantic_ai.messages import ModelResponse, ToolReturn
from pydantic_ai.tools import AgentDepsT
from pydantic_ai.toolsets import DynamicToolset, FunctionToolset
@@ -227,7 +233,10 @@ class
AirflowDurability(BaseDurabilityCapability[AgentDepsT]):
with _active_run(_ActiveRun(durable_run, ledger)):
result = await super().wrap_run(ctx, handler=handler)
except BaseException as error:
- durable_run.fail(error)
+ # pydantic-ai raises UnexpectedModelBehavior when the model's
answers fail its checks
+ # too often (a tool's ModelRetry, output validation): the answers
are recorded, so a
+ # retry has to ask the model again rather than replay them into
the same error.
+ durable_run.fail(error, model_at_fault=isinstance(error,
UnexpectedModelBehavior))
raise
finally:
ledger.settle()
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/durable/journal.py
b/providers/common/ai/src/airflow/providers/common/ai/durable/journal.py
index 4683634c1cd..ffaf955098e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/durable/journal.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/journal.py
@@ -29,7 +29,8 @@ had the same name and the same fingerprint. The name says
what kind of step it w
fingerprint, computed by the framework adapter, says what it was asked (the
message
history, the tool arguments). The first mismatch means the run took a
different path
from the previous attempt, so that step and every step after it run live. So
does
-everything the previous attempt did after a step that raised.
+everything the previous attempt did after a step that raised, and, when the
previous
+attempt failed on the model's answers, everything from the model's last
request on.
A :class:`DurableJournal` belongs to one task attempt. Each agent run in it is
a
:class:`DurableRun`, numbered in the order the runs start, with positions of
its own,
@@ -206,8 +207,14 @@ class DurableRun:
self._peeked: dict[int, dict[str, Any] | None] = {}
# Exceptions steps raised this attempt, with the next position when
each was raised.
self._raised: list[tuple[BaseException, int]] = []
- # The furthest position this attempt recorded a step at.
+ # The furthest position this attempt recorded a step at, and every
position it recorded.
self._last_saved = -1
+ self._saved: set[int] = set()
+ # The position of the last model step this attempt claimed.
+ self._last_model_position: int | None = None
+ # Read with the rest of the run's entry: whether the previous attempt
failed because
+ # of the model's answers, which changes why the retry runs live from
``_diverge_from``.
+ self._model_at_fault = False
@property
def position(self) -> int:
@@ -222,6 +229,7 @@ class DurableRun:
diverge_from, high_water = meta.get("diverge_from"),
meta.get("high_water")
self._diverge_from = diverge_from if isinstance(diverge_from, int)
else None
self._high_water = high_water if isinstance(high_water, int) else 0
+ self._model_at_fault = meta.get("model_at_fault") is True
def peek(self, position: int) -> dict[str, Any] | None:
"""
@@ -256,9 +264,19 @@ class DurableRun:
position = self._position
self._position += 1
step = JournalStep(self, position, name, kind, fingerprint,
replayable=replayable)
+ if kind == "model":
+ self._last_model_position = position
self._load_meta()
if not self.diverged and self._diverge_from is not None and position
>= self._diverge_from:
- self._diverge(position, name, reason="the previous attempt failed
before reaching this step")
+ self._diverge(
+ position,
+ name,
+ reason=(
+ "the model's answers from this step on failed the previous
attempt, so it is asked again"
+ if self._model_at_fault
+ else "the previous attempt failed before reaching this
step"
+ ),
+ )
entry = self.peek(position) if replayable else None
self._peeked.pop(position, None)
previous_replayed, self._previous_replayed = self._previous_replayed,
False
@@ -303,26 +321,57 @@ class DurableRun:
def save(self, position: int, entry: dict[str, Any]) -> bool:
"""Store ``entry`` at ``position``; ``False`` when the storage skipped
it."""
self._last_saved = max(self._last_saved, position)
- return self.journal.storage.save_step(build_step_key(self.key,
position), entry)
+ if not self.journal.storage.save_step(build_step_key(self.key,
position), entry):
+ return False
+ self._saved.add(position)
+ return True
- def fail(self, error: BaseException) -> None:
+ def fail(self, error: BaseException, *, model_at_fault: bool = False) ->
None:
"""
Record that the run failed with ``error``, so a retry does not replay
its error path.
Steps the run started before the step that raised ``error`` (tool
calls running
alongside it, say) still replay on retry; everything from then on,
including what
error handling recorded, runs again.
+
+ :param model_at_fault: The run failed because of what the model
answered rather than
+ in a step, such as the framework giving up after the model's
answers failed its
+ checks too many times. Those answers were recorded and would
replay into the same
+ failure on every retry, so the retry asks the model again from its
last request.
+ Ignored when a step raised ``error``: that step decides where the
retry runs live.
"""
failed_at = self._position
+ raised_in_step = False
cause: BaseException | None = error
while cause is not None:
- failed_at = min([failed_at, *(position for raised, position in
self._raised if raised is cause)])
+ positions = [position for raised, position in self._raised if
raised is cause]
+ raised_in_step = raised_in_step or bool(positions)
+ failed_at = min([failed_at, *positions])
cause = cause.__cause__ or cause.__context__
self._load_meta()
# An attempt that was still replaying an earlier one when it failed,
and recorded
# nothing from the failure on, leaves that earlier attempt's steps as
they were.
diverge_from = failed_at if self.diverged or self._last_saved >=
failed_at else self._diverge_from
- meta = {"diverge_from": diverge_from, "high_water":
max(self._high_water, self._position)}
+ rerun_model = False
+ if model_at_fault and not raised_in_step and (last_model :=
self._last_model_position) is not None:
+ # Unlike a step that raised, the failure is known to come after
every recorded step,
+ # so this holds even for an attempt that was still replaying.
+ if diverge_from is None or last_model <= diverge_from:
+ diverge_from, rerun_model = last_model, True
+ previous = self._diverge_from
+ if (
+ previous is not None
+ and previous not in self._saved
+ and (diverge_from is None or previous < diverge_from)
+ ):
+ # The entry at the previous attempt's point is still that
attempt's, as this one
+ # recorded nothing over it (the step there raised, say), so a
retry must not replay it.
+ diverge_from, rerun_model = previous, self._model_at_fault
+ meta = {
+ "diverge_from": diverge_from,
+ "high_water": max(self._high_water, self._position),
+ "model_at_fault": rerun_model,
+ }
self.journal.storage.save_step(build_run_meta_key(self.key), meta)
def cleanup(self) -> None:
diff --git
a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
index 02fb114ee6b..c747afa147d 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
@@ -28,9 +28,10 @@ import dataclasses
from typing import TYPE_CHECKING, Any
import pytest
+from pydantic import BaseModel
from pydantic_ai import Agent, CancellationToken, RunContext
from pydantic_ai.capabilities import AbstractCapability, durable_operation
-from pydantic_ai.exceptions import ModelRetry
+from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
from pydantic_ai.messages import (
ModelMessage,
ModelResponse,
@@ -494,6 +495,109 @@ class TestAirflowToolsets:
assert calls["model"] == 3
+class Answer(BaseModel):
+ rows: int
+
+
+class TestModelAtFault:
+ """A run pydantic-ai fails because of the model's answers is retried by
asking the model again."""
+
+ @pytest.mark.asyncio
+ async def
test_a_retry_asks_the_model_again_after_its_tool_retries_ran_out(self,
memory_storage):
+ calls = Calls()
+ sql = ["select nope"]
+
+ def model_fn(messages: list[ModelMessage], info: AgentInfo) ->
ModelResponse:
+ calls.bump("model")
+ if returns := tool_returns(messages):
+ return ModelResponse(parts=[TextPart(f"answer:
{returns[-1].content}")])
+ return ModelResponse(parts=[ToolCallPart("query", {"sql":
sql[0]})])
+
+ def build() -> Agent[None, str]:
+ toolset = FunctionToolset(id="db", max_retries=1)
+
+ @toolset.tool_plain
+ def query(sql: str) -> str:
+ calls.bump("query")
+ if "nope" in sql:
+ raise ModelRetry("no column nope")
+ return "3 rows"
+
+ return Agent(
+ FunctionModel(model_fn),
+ name="analyst",
+ toolsets=[toolset],
+ capabilities=[AirflowDurability()],
+ )
+
+ with pytest.raises(UnexpectedModelBehavior, match="exceeded max
retries"):
+ await attempt(memory_storage, build())
+ sql[0] = "select 1"
+ with journal_scope(journal := DurableJournal(memory_storage)):
+ result = await build().run("go")
+
+ assert result.output == "answer: 3 rows"
+ assert dict(journal.stats.replayed) == {"model": 1, "tool": 1}
+ # Attempt 1: two bad queries. Attempt 2 replays the first request and
its rejected
+ # query, asks the model again (good query), then for the answer.
+ assert (calls["model"], calls["query"]) == (4, 3)
+
+ @pytest.mark.asyncio
+ async def
test_a_retry_asks_the_model_again_after_its_output_retries_ran_out(self,
memory_storage):
+ calls = Calls()
+ rows: list[Any] = ["many"]
+
+ def model_fn(messages: list[ModelMessage], info: AgentInfo) ->
ModelResponse:
+ calls.bump("model")
+ return
ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, {"rows":
rows[0]})])
+
+ def build() -> Agent[None, Answer]:
+ return Agent(
+ FunctionModel(model_fn),
+ name="analyst",
+ output_type=Answer,
+ retries=1,
+ capabilities=[AirflowDurability()],
+ )
+
+ with pytest.raises(UnexpectedModelBehavior, match="output retries"):
+ await attempt(memory_storage, build())
+ rows[0] = 3
+ result = await attempt(memory_storage, build())
+
+ assert result.output == Answer(rows=3)
+ # The first invalid answer replays; only the model's last request runs
again.
+ assert calls["model"] == 3
+
+ @pytest.mark.asyncio
+ async def test_a_tool_that_raises_keeps_the_models_request_replaying(self,
memory_storage):
+ """The tool failed, not the model: only the tool runs again."""
+ calls = Calls()
+
+ def build() -> Agent[None, str]:
+ toolset = FunctionToolset(id="db")
+
+ @toolset.tool_plain
+ def query(sql: str) -> str:
+ calls.bump("query")
+ if calls["query"] == 1:
+ raise RuntimeError("connection reset")
+ return "3 rows"
+
+ return Agent(
+ FunctionModel(tool_then_answer(calls, "query",
fail_final=[False])),
+ name="analyst",
+ toolsets=[toolset],
+ capabilities=[AirflowDurability()],
+ )
+
+ with pytest.raises(RuntimeError, match="connection reset"):
+ await attempt(memory_storage, build())
+ await attempt(memory_storage, build())
+
+ assert (calls["model"], calls["query"]) == (2, 2)
+
+
@pytest.mark.enable_redact
class TestMasking:
@pytest.mark.asyncio
diff --git a/providers/common/ai/tests/unit/common/ai/durable/test_journal.py
b/providers/common/ai/tests/unit/common/ai/durable/test_journal.py
index fe962087f08..41f70605409 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_journal.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_journal.py
@@ -25,7 +25,7 @@ from unittest import mock
import pytest
from airflow.providers.common.ai.durable import journal as journal_module
-from airflow.providers.common.ai.durable.base import build_step_key
+from airflow.providers.common.ai.durable.base import build_run_meta_key,
build_step_key
from airflow.providers.common.ai.durable.journal import (
DurableJournal,
current_journal,
@@ -202,6 +202,94 @@ class TestFailures:
assert await retry.run("a", kind="tool", fingerprint="a", body=lambda:
ok("live")) == "recorded"
+ @pytest.mark.asyncio
+ async def test_a_failure_the_model_caused_reruns_its_last_request(self,
memory_storage):
+ """The model's recorded answers would replay into the same failure, so
it is asked again."""
+ first = DurableJournal(memory_storage).start_run()
+ await first.run("model", kind="model", fingerprint="m1", body=lambda:
ok("bad query"))
+ await first.run("query", kind="tool", fingerprint="q1", body=lambda:
ok("retry: no column"))
+ await first.run("model", kind="model", fingerprint="m2", body=lambda:
ok("bad query again"))
+ await first.run("query", kind="tool", fingerprint="q2", body=lambda:
ok("retry: no column"))
+ first.fail(RuntimeError("exceeded max retries"), model_at_fault=True)
+
+ retry = DurableJournal(memory_storage).start_run()
+ results = [
+ await retry.run("model", kind="model", fingerprint="m1",
body=lambda: ok("live")),
+ await retry.run("query", kind="tool", fingerprint="q1",
body=lambda: ok("live")),
+ await retry.run("model", kind="model", fingerprint="m2",
body=lambda: ok("good query")),
+ await retry.run("query", kind="tool", fingerprint="q2",
body=lambda: ok("3 rows")),
+ ]
+
+ assert results == ["bad query", "retry: no column", "good query", "3
rows"]
+ assert dict(retry.journal.stats.replayed) == {"model": 1, "tool": 1}
+
+ def test_a_failure_the_model_caused_is_noted_for_the_retrys_log(self,
memory_storage):
+ first = DurableJournal(memory_storage).start_run()
+ first.claim("model", kind="model", fingerprint="m1").record("bad
query")
+
+ first.fail(RuntimeError("exceeded max retries"), model_at_fault=True)
+
+ meta = memory_storage.load_step(build_run_meta_key(first.key))
+ assert (meta["diverge_from"], meta["model_at_fault"]) == (0, True)
+
+ @pytest.mark.asyncio
+ async def
test_a_failure_the_model_caused_while_replaying_reruns_its_last_request(self,
memory_storage):
+ """Otherwise an attempt that replayed everything would leave the next
one replaying it too."""
+ first = DurableJournal(memory_storage).start_run()
+ await first.run("model", kind="model", fingerprint="m1", body=lambda:
ok("bad"))
+ await first.run("query", kind="tool", fingerprint="q1", body=lambda:
ok("retry"))
+ first.fail(RuntimeError("worker died"))
+ second = DurableJournal(memory_storage).start_run()
+ await second.run("model", kind="model", fingerprint="m1", body=lambda:
ok("live"))
+ await second.run("query", kind="tool", fingerprint="q1", body=lambda:
ok("live"))
+ second.fail(RuntimeError("exceeded max retries"), model_at_fault=True)
+
+ third = DurableJournal(memory_storage).start_run()
+ answer = await third.run("model", kind="model", fingerprint="m1",
body=lambda: ok("asked again"))
+
+ assert answer == "asked again"
+
+ @pytest.mark.asyncio
+ async def
test_a_model_request_asked_again_that_raises_is_asked_again_on_the_next_retry(
+ self, memory_storage
+ ):
+ """The rejected answer is still recorded at that position, so it must
not replay."""
+ first = DurableJournal(memory_storage).start_run()
+ await first.run("model", kind="model", fingerprint="m1", body=lambda:
ok("bad query"))
+ await first.run("query", kind="tool", fingerprint="q1", body=lambda:
ok("retry: no column"))
+ first.fail(RuntimeError("exceeded max retries"), model_at_fault=True)
+ second = DurableJournal(memory_storage).start_run()
+ rate_limited = RuntimeError("429")
+ with pytest.raises(RuntimeError):
+ await second.run("model", kind="model", fingerprint="m1",
body=lambda: raise_(rate_limited))
+ second.fail(rate_limited)
+
+ third = DurableJournal(memory_storage).start_run()
+ answer = await third.run("model", kind="model", fingerprint="m1",
body=lambda: ok("good query"))
+
+ assert answer == "good query"
+
+ @pytest.mark.asyncio
+ async def
test_a_step_that_raised_the_error_decides_even_when_the_model_is_blamed(self,
memory_storage):
+ """An agent a tool runs can give up on its own model; the outer
model's answer was fine."""
+ first = DurableJournal(memory_storage).start_run()
+ await first.run("model", kind="model", fingerprint="m1", body=lambda:
ok("call both tools"))
+ error = RuntimeError("nested agent exceeded max retries")
+ nested = first.claim("nested_agent", kind="tool", fingerprint="n")
+ sibling = first.claim("charge_card", kind="tool", fingerprint="c")
+ nested.fail(error)
+ sibling.record("charged")
+ first.fail(error, model_at_fault=True)
+
+ retry = DurableJournal(memory_storage).start_run()
+ model = await retry.run("model", kind="model", fingerprint="m1",
body=lambda: ok("asked again"))
+ await retry.run("nested_agent", kind="tool", fingerprint="n",
body=lambda: ok("ok"))
+ charge = await retry.run(
+ "charge_card", kind="tool", fingerprint="c", body=lambda:
ok("charged twice")
+ )
+
+ assert (model, charge) == ("call both tools", "charged")
+
@pytest.mark.asyncio
async def
test_a_step_without_a_fingerprint_does_not_replay_after_a_live_step(self,
memory_storage):
"""The live step may have produced something else, which the
unfingerprinted step would depend on."""