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 0eab1db3d2b Replay an OpenAI Agents SDK agent's completed calls when 
Airflow retries its task (#74770)
0eab1db3d2b is described below

commit 0eab1db3d2b999332d1a4e759e3ff401f18da188
Author: Kaxil Naik <[email protected]>
AuthorDate: Sat Oct 17 13:55:15 2026 +0100

    Replay an OpenAI Agents SDK agent's completed calls when Airflow retries 
its task (#74770)
    
    run_durably() and run_durably_sync() run an agent like Runner.run, recording
    each model call and function tool call in the task's durable journal, the 
one
    AgentOperator(durable=True) uses. A retry replays every call that asks what 
the
    failed try asked, in order, and runs live from the first that differs. A run
    the model failed (max turns, malformed output, output guardrail) asks the
    model again from its last request; an agent called through as_tool replays 
as
    one call; server-side conversations are refused because a replay never
    reaches them.
    
    A function tool's exception that failure_error_function turned into text was
    recorded as the tool's result, so a retry replayed the error and never ran 
the
    tool again. Such a call is no longer recorded, as a call that raised is not.
    The guide now splits what a retry replays from what always runs live, names
    the journal's storage, and the tests cover unreadable recordings, structured
    results, sessions, and a tool-raised ModelBehaviorError.
---
 providers/common/ai/docs/durable_execution.rst     |   3 +-
 providers/common/ai/docs/features.rst              |   3 +-
 .../common/ai/docs/frameworks/openai_agents.rst    |  72 ++-
 providers/common/ai/docs/stability.rst             |   3 +-
 .../providers/common/ai/durable/openai_agents.py   | 479 +++++++++++++++++++
 .../example_dags/example_openai_agents_durable.py  |  90 ++++
 .../unit/common/ai/durable/test_openai_agents.py   | 521 +++++++++++++++++++++
 7 files changed, 1166 insertions(+), 5 deletions(-)

diff --git a/providers/common/ai/docs/durable_execution.rst 
b/providers/common/ai/docs/durable_execution.rst
index 53d09702d74..6c0d34645ea 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -30,7 +30,8 @@ them again. Only the steps the failed attempt never finished 
run live.
 Durable execution only helps a task that has retries. To decide *whether* a 
failure is
 worth retrying, see :doc:`retry_policies`; a retried ``LLMBatchOperator`` 
re-attaches to
 its running batch instead (:ref:`llm-batch-reattach`). To resume a Strands 
Agents agent on a
-retry, see :ref:`howto/frameworks:strands-durable`.
+retry, see :ref:`howto/frameworks:strands-durable`; for an OpenAI Agents SDK 
agent, see
+:ref:`howto/frameworks:openai-agents-durable`.
 
 Turn it on
 ----------
diff --git a/providers/common/ai/docs/features.rst 
b/providers/common/ai/docs/features.rst
index 7dc46e3f8cb..94fc60c2654 100644
--- a/providers/common/ai/docs/features.rst
+++ b/providers/common/ai/docs/features.rst
@@ -272,7 +272,8 @@ toolset except
      - Gives a loop on the Anthropic SDK's ``tool_runner`` the toolsets.
      - ``anthropic``
    * - :doc:`OpenAI Agents SDK <frameworks/openai_agents>`
-     - Gives an OpenAI Agents SDK agent the toolsets.
+     - Gives an OpenAI Agents SDK agent the toolsets. Run through 
``run_durably``, a retry
+       replays its completed calls.
      - None; install ``openai-agents``
    * - :doc:`LangChain <hooks/langchain>`
      - Builds LangChain chat and embedding models from a connection, and
diff --git a/providers/common/ai/docs/frameworks/openai_agents.rst 
b/providers/common/ai/docs/frameworks/openai_agents.rst
index 103caf46a53..a704a4298d9 100644
--- a/providers/common/ai/docs/frameworks/openai_agents.rst
+++ b/providers/common/ai/docs/frameworks/openai_agents.rst
@@ -142,11 +142,79 @@ To turn the SDK's tracing off altogether, call 
``agents.set_tracing_disabled(Tru
 ``OPENAI_AGENTS_DISABLE_TRACING=1`` in the worker's environment. That stops
 ``AirflowTraceProcessor`` from receiving anything too.
 
+.. _howto/frameworks:openai-agents-durable:
+
+Durable execution
+-----------------
+
+When a task that calls ``Runner.run`` fails and Airflow retries it, the agent 
starts
+again from the prompt: every model call is paid for again, and every tool call 
happens
+again. On a task with ``retries``, run the agent with
+:func:`~airflow.providers.common.ai.durable.openai_agents.run_durably_sync`, or
+:func:`~airflow.providers.common.ai.durable.openai_agents.run_durably` from 
async code,
+instead. They take the same arguments as ``Runner.run_sync`` and 
``Runner.run``, except
+``previous_response_id``, ``conversation_id`` and 
``auto_previous_response_id``, which they
+refuse: with them OpenAI keeps the conversation, and a replayed call never 
reaches it.
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_openai_agents_durable.py
+    :language: python
+    :start-after: [START example_openai_agents_durable]
+    :end-before: [END example_openai_agents_durable]
+
+Each model call and function tool call is recorded in the task instance's 
durable
+journal as it completes, the journal ``AgentOperator(durable=True)`` uses (see
+:doc:`../durable_execution`). On Airflow 3.3 and later the journal is kept in 
the task
+state store; on earlier versions, set ``[common.ai] durable_cache_path`` to 
where it is
+kept. Once the run succeeds, what was recorded is deleted, so clearing the 
task later
+starts over. Outside a task there is nothing to replay into, and the agent 
runs as
+``Runner.run`` runs it.
+
+What a retry replays
+^^^^^^^^^^^^^^^^^^^^
+
+On a retry the agent runs again from the start, and each call that asks what 
the failed
+try asked, in the same order, returns what it returned then: the calls of 
every model
+given by name or in ``run_config``, the model and function tools of the 
starting agent
+and of the agents in its ``handoffs`` list, and an agent used through 
``as_tool``, as one
+call. Recorded tool results pass through Airflow's secret masker before they 
are saved,
+and a replayed model call reports no token usage.
+
+From the first call that differs, that call and every call after it run live:
+
+- A retry whose prompt, instructions, model settings or tools differ from the 
failed
+  try's runs everything live from the first call that sees the difference.
+- When the run failed because of the model's answers (``MaxTurnsExceeded``,
+  ``ModelBehaviorError``, ``ModelRefusalError``, or an output or tool input 
guardrail),
+  replaying those answers would fail it the same way, so the retry asks the 
model again
+  from its last request.
+
+What always runs live
+^^^^^^^^^^^^^^^^^^^^^
+
+- A call that was running when the try failed, so tools that change data must 
still be
+  safe to repeat.
+- A tool call that failed, including one whose exception 
``failure_error_function`` turned
+  into text for the model. A ``ModelBehaviorError`` that a tool raises, such 
as arguments
+  that fail validation with ``failure_error_function=None``, counts as the 
tool's failure:
+  the tool call runs again, but the answer that asked for it replays.
+- Tools other than function tools: hosted tools such as web search, the tools 
of
+  ``mcp_servers``, and shell and computer tools.
+- A function tool whose ``needs_approval`` is a function, since the SDK checks 
the
+  arguments it approved only through its own invoker.
+- The function tools of an agent reached through ``handoff()``, and its model 
too unless
+  the agent's ``model`` is a model name or not set.
+- A streamed run.
+- Every call, when the run has a ``session``: the SDK adds the failed try's 
prompt to it
+  before the first model call, so that call no longer asks what it asked then.
+
+A ``SandboxToolset`` without ``attach_to`` is destroyed when the try ends, so 
the retry
+starts with a new, empty sandbox that the replayed conversation does not 
describe.
+
 Differences from ``AgentOperator``
 ----------------------------------
 
-- A task retry runs the agent again from the start, so tools that change data 
must be
-  safe to repeat.
+- A task retry runs the agent again from the start unless it runs through
+  ``run_durably``; see :ref:`howto/frameworks:openai-agents-durable`.
 - There is no human review step like 
``AgentOperator(enable_hitl_review=True)``, and the
   SDK's own ``needs_approval`` interruptions are not connected to Airflow's 
approval UI.
 
diff --git a/providers/common/ai/docs/stability.rst 
b/providers/common/ai/docs/stability.rst
index cced251110c..5feb431be94 100644
--- a/providers/common/ai/docs/stability.rst
+++ b/providers/common/ai/docs/stability.rst
@@ -261,7 +261,8 @@ Everything this provider ships that is not in the table 
above is experimental.
    * - The Anthropic tool runner adapter (:doc:`frameworks/anthropic`)
      - Built on ``tool_runner``, a beta helper of the Anthropic SDK that can 
change between
        SDK releases.
-   * - The OpenAI Agents SDK adapter and trace processor 
(:doc:`frameworks/openai_agents`)
+   * - The OpenAI Agents SDK adapter, trace processor and ``run_durably``
+       (:doc:`frameworks/openai_agents`)
      - Written against OpenAI Agents SDK 0.20 and 0.23. The SDK is pre-1.0 and 
changes
        its tool and runner APIs between minor releases.
    * - The ``common_ai.tool_calls`` metric and
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/durable/openai_agents.py 
b/providers/common/ai/src/airflow/providers/common/ai/durable/openai_agents.py
new file mode 100644
index 00000000000..1224565c184
--- /dev/null
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/durable/openai_agents.py
@@ -0,0 +1,479 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Replay an OpenAI Agents SDK agent's completed model and tool calls when 
Airflow retries its task.
+
+.. note:: Experimental; see :ref:`howto/stability`.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+from typing import TYPE_CHECKING, Any
+
+from pydantic import TypeAdapter, ValidationError
+from pydantic_core import to_jsonable_python
+
+from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
+
+try:
+    from agents import (
+        Agent,
+        FunctionTool,
+        Model,
+        ModelProvider,
+        ModelResponse,
+        RunConfig,
+        Runner,
+        ToolOutputFileContent,
+        ToolOutputImage,
+        ToolOutputText,
+        Usage,
+    )
+    from agents.exceptions import (
+        MaxTurnsExceeded,
+        ModelBehaviorError,
+        ModelRefusalError,
+        OutputGuardrailTripwireTriggered,
+        ToolInputGuardrailTripwireTriggered,
+    )
+    from agents.items import TResponseOutputItem
+    from agents.tool import (
+        default_tool_error_function,
+        resolve_function_tool_failure_error_function,
+        set_function_tool_failure_error_function,
+    )
+except ImportError as e:
+    raise AirflowOptionalProviderFeatureException(e)
+
+from airflow.providers.common.ai.durable.fingerprint import _digest, 
fingerprint_tool_call
+from airflow.providers.common.ai.durable.journal import _EXECUTING_STEP, 
current_journal
+from airflow.providers.common.ai.utils.coroutines import run_coroutine_sync
+from airflow.providers.common.ai.utils.masking import mask_secrets
+from airflow.providers.common.ai.utils.task_logger import get_task_logger
+
+if TYPE_CHECKING:
+    from collections.abc import AsyncIterator
+
+    from agents import AgentOutputSchemaBase, Handoff, ModelSettings, 
ModelTracing, RunResult, Tool
+    from agents.items import TResponseInputItem, TResponseStreamEvent
+    from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest
+    from agents.tool_context import ToolContext
+
+    from airflow.providers.common.ai.durable.journal import DurableRun, 
JournalStep
+
+__all__ = ["run_durably", "run_durably_sync"]
+
+log = get_task_logger()
+
+_OUTPUT_ITEMS: TypeAdapter[list[TResponseOutputItem]] = 
TypeAdapter(list[TResponseOutputItem])
+
+# Errors that come from the model's answers rather than from a step. The 
answers were
+# recorded, so a retry must ask the model again instead of replaying them into 
the same error.
+_MODEL_AT_FAULT = (
+    ModelBehaviorError,
+    ModelRefusalError,
+    MaxTurnsExceeded,
+    OutputGuardrailTripwireTriggered,
+    ToolInputGuardrailTripwireTriggered,
+)
+
+# The tool results the SDK hands the model as structured content rather than 
as text.
+_STRUCTURED_OUTPUTS = (ToolOutputText, ToolOutputImage, ToolOutputFileContent)
+
+# Set on a tool call's context by the SDK when its default 
failure_error_function turned the
+# tool's exception into the text the model sees.
+_DEFAULT_FAILURE_HANDLED = "_function_tool_default_failure_handled"
+
+# Arguments that keep the conversation on OpenAI's servers, which a replayed 
model call never reaches.
+_SERVER_SIDE_STATE = ("previous_response_id", "conversation_id", 
"auto_previous_response_id")
+
+
+async def run_durably(
+    starting_agent: Agent[Any],
+    input: str | list[TResponseInputItem],
+    *,
+    run_config: RunConfig | None = None,
+    **kwargs: Any,
+) -> RunResult:
+    """
+    Run an agent like ``Runner.run``, so that a retry of the task replays what 
already completed.
+
+    Each model call and each function tool call the run makes is recorded in 
the task's
+    durable journal as it completes. When the task fails and Airflow retries 
it, the agent
+    runs again from the start, and every call that asks what the previous 
attempt asked,
+    in the same order, returns the recorded result instead of calling the 
model or the tool
+    again. From the first call that differs, such as a model answer that 
changed, that call
+    and every call after it run live. Once the run succeeds what was recorded 
is deleted, so
+    clearing the task later starts the agent over.
+
+    .. code-block:: python
+
+        from agents import Agent
+
+        from airflow.providers.common.ai.durable.openai_agents import 
run_durably_sync
+        from airflow.providers.common.ai.tools.openai_agents import 
AirflowTools
+        from airflow.sdk import task
+
+
+        @task(retries=3)
+        def research(question: str) -> str:
+            agent = Agent(name="analyst", model=model, 
tools=AirflowTools(warehouse))
+            return run_durably_sync(agent, question).final_output
+
+    Recorded are the calls of every model given by name or in ``run_config``, 
the model and
+    function tools of the starting agent and of the agents in its ``handoffs`` 
list, and an
+    agent used through ``as_tool``, as one call. These run live on every 
attempt: tools
+    other than function tools (hosted tools such as web search, the tools of
+    ``mcp_servers``, shell and computer tools), a function tool whose 
``needs_approval`` is
+    a function, since the SDK checks its arguments only through its own 
invoker, the
+    function tools of an agent reached through ``handoff()``, and its model 
too unless the
+    agent's ``model`` is a model name or not set, and streamed runs. A tool 
call that fails,
+    including one whose exception ``failure_error_function`` turns into text 
for the model,
+    is not recorded either, so a retry runs it again. A replayed model call 
reports no
+    usage, since it costs nothing.
+
+    A ``session`` makes every retry run live: the SDK adds the failed try's 
prompt to it
+    before the first model call, so that call no longer asks what it asked 
then.
+
+    Outside a task there is no journal, and this is ``Runner.run``.
+
+    :param starting_agent: The agent to run.
+    :param input: The prompt, as for ``Runner.run``.
+    :param run_config: As for ``Runner.run``.
+    :param kwargs: Passed to ``Runner.run``, such as ``max_turns`` or 
``context``.
+    :raises ValueError: When ``previous_response_id``, ``conversation_id`` or
+        ``auto_previous_response_id`` is passed: a replayed call never reaches 
the
+        conversation OpenAI keeps for them.
+    """
+    if (journal := current_journal()) is None:
+        return await Runner.run(starting_agent, input, run_config=run_config, 
**kwargs)
+    if stateful := [name for name in _SERVER_SIDE_STATE if kwargs.get(name)]:
+        raise ValueError(
+            f"run_durably() cannot replay a run that keeps its conversation on 
OpenAI's servers "
+            f"({', '.join(stateful)}): a replayed model call never reaches it."
+        )
+    durable_run = journal.start_run()
+    config = copy.copy(run_config) if run_config is not None else RunConfig()
+    config.model_provider = _DurableModelProvider(config.model_provider, 
durable_run)
+    if isinstance(config.model, Model):
+        config.model = _DurableModel(config.model, durable_run)
+    agent = _get_durable_agent(starting_agent, durable_run, {})
+    try:
+        result = await Runner.run(agent, input, run_config=config, **kwargs)
+    except BaseException as error:
+        durable_run.fail(error, model_at_fault=isinstance(error, 
_MODEL_AT_FAULT))
+        raise
+    if journal.clean_up_after_run:
+        durable_run.cleanup()
+    return result
+
+
+def run_durably_sync(
+    starting_agent: Agent[Any],
+    input: str | list[TResponseInputItem],
+    *,
+    run_config: RunConfig | None = None,
+    **kwargs: Any,
+) -> RunResult:
+    """Run :func:`run_durably` from synchronous code, such as a plain 
``@task``."""
+    return run_coroutine_sync(run_durably(starting_agent, input, 
run_config=run_config, **kwargs))
+
+
+def _get_durable_agent(agent: Agent[Any], durable_run: DurableRun, seen: 
dict[int, Agent[Any]]) -> Agent[Any]:
+    """Return a copy of ``agent`` whose model, function tools and handoff 
agents go through the journal."""
+    if (done := seen.get(id(agent))) is not None:
+        return done
+    durable_agent = agent.clone()
+    # Recorded before the handoffs are copied, so agents that hand off to each 
other copy once.
+    seen[id(agent)] = durable_agent
+    if isinstance(agent.model, Model):
+        durable_agent.model = _DurableModel(agent.model, durable_run)
+    durable_agent.tools = [_get_durable_tool(tool, durable_run) for tool in 
agent.tools]
+    durable_agent.handoffs = [
+        _get_durable_agent(target, durable_run, seen) if isinstance(target, 
Agent) else target
+        for target in agent.handoffs
+    ]
+    return durable_agent
+
+
+def _get_durable_tool(tool: Tool, durable_run: DurableRun) -> Tool:
+    # A tool whose approval depends on its arguments is left as it is: the SDK 
checks that the
+    # arguments it approved are the ones the tool runs with only through its 
own invoker.
+    if not isinstance(tool, FunctionTool) or callable(tool.needs_approval):
+        return tool
+    # A shallow copy, not dataclasses.replace: the tool's schema is already 
prepared.
+    durable_tool = copy.copy(tool)
+    invoke = tool.on_invoke_tool
+    # Exceptions a custom failure_error_function turned into text, by tool 
call.
+    handled: dict[str, Exception] = {}
+    formatter = resolve_function_tool_failure_error_function(tool)
+    bind = getattr(invoke, "__agents_bind_function_tool__", None)
+    if formatter is not None and formatter is not default_tool_error_function 
and bind is not None:
+
+        def remember(ctx: Any, error: Exception) -> Any:
+            handled[ctx.tool_call_id] = error
+            return formatter(ctx, error)
+
+        set_function_tool_failure_error_function(durable_tool, remember)
+        invoke = bind(durable_tool)
+
+    async def on_invoke_tool(ctx: ToolContext[Any], arguments: str) -> Any:
+        if _is_inside_a_call(durable_run):
+            return await invoke(ctx, arguments)
+        try:
+            parsed = json.loads(arguments) if arguments else {}
+        except json.JSONDecodeError:
+            parsed = arguments
+        fingerprint = fingerprint_tool_call(
+            tool.name,
+            # As recorded model answers are masked, so a replayed call's 
arguments can be too.
+            mask_secrets(parsed if isinstance(parsed, dict) else {"arguments": 
parsed}),
+            ctx.tool_call_id,
+        )
+        step = durable_run.claim(f"tool:{tool.name}", kind="tool", 
fingerprint=fingerprint)
+        if step.replayed:
+            return step.payload
+        try:
+            with step.executing():
+                output = await invoke(ctx, arguments)
+        except Exception as error:
+            step.fail(error)
+            raise
+        # A failure the SDK turned into text for the model is not recorded, so 
a retry runs the
+        # call again, as it does a call that raised.
+        if (handled_error := handled.pop(ctx.tool_call_id, None)) is not None:
+            step.fail(handled_error)
+        elif not getattr(ctx, _DEFAULT_FAILURE_HANDLED, False):
+            # Secrets must not reach the journal any more than the model.
+            step.record(mask_secrets(_as_sent(output)))
+        return output
+
+    durable_tool.on_invoke_tool = on_invoke_tool
+    return durable_tool
+
+
+def _as_sent(output: Any) -> Any:
+    """
+    Return a tool's result in the form the SDK sends it to the model, as a 
JSON-compatible value.
+
+    The SDK sends a ``ToolOutputText``, ``ToolOutputImage`` or 
``ToolOutputFileContent``, a
+    ``dict`` with a ``type``, or a list of them, as structured content, and 
anything else as
+    ``str(output)``. Recording anything else as it is would replay it as a 
different text
+    (a model's fields as a dict, a tuple as a list), so the next model call 
would no longer
+    match the one the failed try made.
+    """
+    if isinstance(output, str):
+        return output
+    items = output if isinstance(output, list | tuple) else [output]
+    if items and all(
+        isinstance(item, _STRUCTURED_OUTPUTS) or (isinstance(item, dict) and 
"type" in item) for item in items
+    ):
+        return to_jsonable_python(output)
+    return str(output)
+
+
+def _is_inside_a_call(durable_run: DurableRun) -> bool:
+    """
+    Whether a recorded call of ``durable_run`` is running, such as an agent 
called through ``as_tool``.
+
+    That call is recorded as one step, and a retry replays it whole, so what 
it does inside
+    runs live rather than taking positions of the run it belongs to.
+    """
+    step = _EXECUTING_STEP.get()
+    return step is not None and step.durable_run is durable_run
+
+
+class _DurableModelProvider(ModelProvider):
+    """Wrap every model the run resolves by name in a 
:class:`_DurableModel`."""
+
+    def __init__(self, provider: ModelProvider, durable_run: DurableRun) -> 
None:
+        self.provider = provider
+        self.durable_run = durable_run
+        self.models: dict[str | None, _DurableModel] = {}
+
+    def get_model(self, model_name: str | None) -> Model:
+        if model_name not in self.models:
+            self.models[model_name] = 
_DurableModel(self.provider.get_model(model_name), self.durable_run)
+        return self.models[model_name]
+
+
+class _DurableModel(Model):
+    """Replay a model call the previous attempt recorded, or make it and 
record the response."""
+
+    def __init__(self, model: Model, durable_run: DurableRun) -> None:
+        self.wrapped = model
+        self.durable_run = durable_run
+        # The step of a call that raised, which the SDK's own model retry may 
make again.
+        self.failed_step: JournalStep | None = None
+
+    async def get_response(
+        self,
+        system_instructions: str | None,
+        input: str | list[TResponseInputItem],
+        model_settings: ModelSettings,
+        tools: list[Tool],
+        output_schema: AgentOutputSchemaBase | None,
+        handoffs: list[Handoff],
+        tracing: ModelTracing,
+        **kwargs: Any,
+    ) -> ModelResponse:
+        if _is_inside_a_call(self.durable_run):
+            return await self.wrapped.get_response(
+                system_instructions, input, model_settings, tools, 
output_schema, handoffs, tracing, **kwargs
+            )
+        fingerprint = _fingerprint_request(
+            self.wrapped, system_instructions, input, model_settings, tools, 
output_schema, handoffs, kwargs
+        )
+        step = self._retried(fingerprint) or self.durable_run.claim(
+            "model", kind="model", fingerprint=fingerprint
+        )
+        if step.replayed:
+            try:
+                return ModelResponse(
+                    
output=_OUTPUT_ITEMS.validate_python(step.payload["output"]),
+                    usage=Usage(),
+                    response_id=step.payload.get("response_id"),
+                )
+            except (ValidationError, KeyError, TypeError) as error:
+                self.durable_run.reject(step, reason=f"the recorded model 
response cannot be read: {error}")
+
+        async def call() -> ModelResponse:
+            return await self.wrapped.get_response(
+                system_instructions, input, model_settings, tools, 
output_schema, handoffs, tracing, **kwargs
+            )
+
+        try:
+            return await step.run(call, to_record=_record_response)
+        except Exception:
+            self.failed_step = step
+            raise
+
+    def _retried(self, fingerprint: str | None) -> JournalStep | None:
+        """
+        Return the step of the call that just raised, when this call is the 
SDK retrying it.
+
+        The SDK's model retry asks again with the same request and nothing in 
between; it is
+        the same call, so it takes the same position, and the next try lines 
up with this one.
+        """
+        failed, self.failed_step = self.failed_step, None
+        if failed is None or failed.fingerprint != fingerprint:
+            return None
+        return failed if self.durable_run.position == failed.position + 1 else 
None
+
+    def stream_response(
+        self,
+        system_instructions: str | None,
+        input: str | list[TResponseInputItem],
+        model_settings: ModelSettings,
+        tools: list[Tool],
+        output_schema: AgentOutputSchemaBase | None,
+        handoffs: list[Handoff],
+        tracing: ModelTracing,
+        **kwargs: Any,
+    ) -> AsyncIterator[TResponseStreamEvent]:
+        # A streamed response is not recorded: it reaches the caller event by 
event.
+        return self.wrapped.stream_response(
+            system_instructions, input, model_settings, tools, output_schema, 
handoffs, tracing, **kwargs
+        )
+
+    def _supports_default_prompt_cache_key(self) -> bool:
+        # The runner asks the model whether to add a prompt cache key; the 
answer is the wrapped model's.
+        supports = getattr(self.wrapped, "_supports_default_prompt_cache_key", 
None)
+        return bool(supports()) if callable(supports) else False
+
+    def get_retry_advice(self, request: ModelRetryAdviceRequest) -> 
ModelRetryAdvice | None:
+        return self.wrapped.get_retry_advice(request)
+
+    async def _cleanup_on_run_end(self, owner: object) -> None:
+        await self.wrapped._cleanup_on_run_end(owner)
+
+    async def close(self) -> None:
+        await self.wrapped.close()
+
+
+def _record_response(response: ModelResponse) -> dict[str, Any]:
+    # Only the fields the response set, so a replayed item turns into the same 
next request as
+    # the live one: the SDK carries every field of an item into the 
conversation it sends.
+    output = [item.model_dump(mode="json", exclude_unset=True) for item in 
response.output]
+    # Masked, as the journal's other steps are; a replay hands the model its 
own masked answer.
+    return {"output": mask_secrets(output), "response_id": 
response.response_id}
+
+
+def _fingerprint_request(
+    model: Model,
+    system_instructions: str | None,
+    input: str | list[TResponseInputItem],
+    model_settings: ModelSettings,
+    tools: list[Tool],
+    output_schema: AgentOutputSchemaBase | None,
+    handoffs: list[Handoff],
+    options: dict[str, Any],
+) -> str | None:
+    """
+    Fingerprint what a model call asks: the model, instructions, conversation, 
settings and tools.
+
+    ``None`` when the request cannot be serialized; the call then replays on 
position alone,
+    and only right after a call that replayed.
+    """
+    try:
+        settings = to_jsonable_python(model_settings)
+        # Generated once per run, so it differs on every attempt without 
changing the request.
+        if isinstance(extra := settings.get("extra_args"), dict):
+            extra.pop("prompt_cache_key", None)
+        payload = {
+            "model": 
f"{type(model).__module__}.{type(model).__qualname__}:{getattr(model, 'model', 
None)}",
+            "instructions": system_instructions,
+            # Masked as the recorded steps are, so a replayed conversation 
matches a live one.
+            "input": _without_none(mask_secrets(to_jsonable_python(input))),
+            "settings": settings,
+            "tools": [_describe_tool(tool) for tool in tools],
+            "output": output_schema.json_schema()
+            if output_schema and not output_schema.is_plain_text()
+            else None,
+            "handoffs": [[handoff.tool_name, handoff.input_json_schema] for 
handoff in handoffs],
+            "prompt": to_jsonable_python(options.get("prompt")),
+        }
+        return _digest(payload)
+    except (TypeError, ValueError):
+        log.warning(
+            "Durable: could not fingerprint the model request; its recorded 
response replays "
+            "without verification"
+        )
+        return None
+
+
+def _without_none(value: Any) -> Any:
+    """
+    Drop ``None`` fields, so an item that leaves a field unset matches one 
that sets it to ``None``.
+
+    Which of the two the SDK sends depends on how the item was built, not on 
what was asked.
+    """
+    if isinstance(value, dict):
+        return {key: _without_none(item) for key, item in value.items() if 
item is not None}
+    if isinstance(value, list):
+        return [_without_none(item) for item in value]
+    return value
+
+
+def _describe_tool(tool: Tool) -> Any:
+    """Return what the model is told about a tool, for the request 
fingerprint."""
+    if isinstance(tool, FunctionTool):
+        return [tool.name, tool.description, tool.params_json_schema, 
tool.strict_json_schema]
+    # A hosted tool's configuration, such as a file search's vector stores, 
without its callbacks.
+    return [type(tool).__name__, to_jsonable_python(tool, fallback=lambda 
value: type(value).__name__)]
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_openai_agents_durable.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_openai_agents_durable.py
new file mode 100644
index 00000000000..ca27e8da8ab
--- /dev/null
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_openai_agents_durable.py
@@ -0,0 +1,90 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Run an OpenAI Agents SDK agent whose completed model and tool calls a task 
retry replays.
+
+The agent is the one in ``example_openai_agents_agent``: a model whose API key 
comes from
+an Airflow connection, and a ``SQLToolset``'s tools through
+:class:`~airflow.providers.common.ai.tools.openai_agents.AirflowTools`.
+:func:`~airflow.providers.common.ai.durable.openai_agents.run_durably_sync` 
runs it, so
+when the task fails and Airflow retries it, the calls the failed try completed 
return
+what they returned then instead of running again.
+
+Before running:
+
+1. Install the SDK: ``pip install "openai-agents>=0.20.0"``.
+2. Create a connection (``LLM_CONN_ID``, default ``openai_default``) whose 
password is
+   an OpenAI API key. Set its host to the base URL of any other 
OpenAI-compatible
+   endpoint, such as a gateway or a self-hosted server.
+3. Create a database connection (``DB_CONN_ID``, default ``sql_default``) whose
+   hook is a ``DbApiHook`` (e.g. SQLite, Postgres, MySQL).
+4. On Airflow before 3.3, set ``[common.ai] durable_cache_path`` to where the 
journal
+   is kept; from 3.3 on it is kept in the task state store.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import timedelta
+
+from airflow.providers.common.compat.sdk import BaseHook, dag, task
+
+LLM_CONN_ID = os.environ.get("LLM_CONN_ID", "openai_default")
+LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-5.6-luna")
+DB_CONN_ID = os.environ.get("DB_CONN_ID", "sql_default")
+
+DEFAULT_QUESTION = "Which tables exist, and how many rows does each contain?"
+
+
+# [START example_openai_agents_durable]
+@dag(tags=["example"])
+def example_openai_agents_durable():
+    """Answer a question about a database with an OpenAI Agents SDK agent that 
survives task retries."""
+
+    @task(retries=2, retry_delay=timedelta(minutes=1))
+    def run_durable_openai_agent(question: str = DEFAULT_QUESTION) -> str:
+        from agents import Agent, OpenAIChatCompletionsModel, RunConfig
+        from openai import AsyncOpenAI
+
+        from airflow.providers.common.ai.durable.openai_agents import 
run_durably_sync
+        from airflow.providers.common.ai.tools.openai_agents import 
AirflowTools
+        from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+        llm = BaseHook.get_connection(LLM_CONN_ID)
+        agent = Agent(
+            name="analyst",
+            model=OpenAIChatCompletionsModel(
+                model=LLM_MODEL,
+                openai_client=AsyncOpenAI(api_key=llm.password, 
base_url=llm.host or None),
+            ),
+            instructions=(
+                "You are a SQL analyst. Use list_tables and get_schema to 
explore "
+                "the database, then run read-only queries to answer the 
question."
+            ),
+            tools=AirflowTools(SQLToolset(db_conn_id=DB_CONN_ID)),
+        )
+        # The SDK sends traces to OpenAI by default; see the guide's tracing 
section.
+        result = run_durably_sync(agent, question, 
run_config=RunConfig(tracing_disabled=True))
+        return result.final_output
+
+    run_durable_openai_agent()
+
+
+# [END example_openai_agents_durable]
+
+
+example_openai_agents_durable()
diff --git 
a/providers/common/ai/tests/unit/common/ai/durable/test_openai_agents.py 
b/providers/common/ai/tests/unit/common/ai/durable/test_openai_agents.py
new file mode 100644
index 00000000000..30baff77db8
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_openai_agents.py
@@ -0,0 +1,521 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import contextlib
+import datetime
+import json
+from typing import Any
+
+import pytest
+
+pytest.importorskip("agents")
+
+from agents import (
+    Agent,
+    Model,
+    ModelProvider,
+    ModelResponse,
+    ModelSettings,
+    RunConfig,
+    SQLiteSession,
+    ToolOutputText,
+    Usage,
+    function_tool,
+)
+from agents.exceptions import MaxTurnsExceeded, ModelBehaviorError
+from agents.retry import ModelRetryBackoffSettings, ModelRetrySettings
+from openai.types.responses import ResponseFunctionToolCall, 
ResponseOutputMessage, ResponseOutputText
+from pydantic import BaseModel
+
+from airflow.providers.common.ai.durable.base import build_step_key
+from airflow.providers.common.ai.durable.journal import DurableJournal, 
journal_scope
+from airflow.providers.common.ai.durable.openai_agents import 
_get_durable_tool, run_durably_sync
+from airflow.providers.common.ai.tools import AirflowTool, ToolResult
+from airflow.providers.common.ai.tools.openai_agents import AirflowTools
+
+from unit.common.ai.durable.memory_storage import MemoryStorage
+
+# Tracing off, so no test exports spans to OpenAI.
+NO_TRACING = RunConfig(tracing_disabled=True)
+
+
+class _ScriptedModel(Model):
+    """
+    Calls ``lookup`` until it has ``lookups`` results back, then answers with 
the last one.
+
+    ``calls`` counts the requests that reached the model. ``fail_on_answer`` 
makes the
+    request that would answer raise instead, as a crashed worker would leave 
it.
+    """
+
+    def __init__(
+        self,
+        *,
+        lookups: int = 1,
+        fail_on_answer: bool = False,
+        usage: int = 7,
+        argument: str = "key",
+        fail_first_request: bool = False,
+    ) -> None:
+        self.lookups = lookups
+        self.argument = argument
+        self.fail_first_request = fail_first_request
+        self.fail_on_answer = fail_on_answer
+        self.usage = usage
+        self.calls = 0
+        # How many tool results each request that reached the model carried.
+        self.tool_results_seen: list[int] = []
+
+    async def get_response(self, system_instructions, input, *args: Any, 
**kwargs: Any) -> ModelResponse:
+        self.calls += 1
+        if self.fail_first_request and self.calls == 1:
+            raise ConnectionError("connection reset")
+        outputs = [
+            item["output"]
+            for item in (input if isinstance(input, list) else [])
+            if isinstance(item, dict) and item.get("type") == 
"function_call_output"
+        ]
+        self.tool_results_seen.append(len(outputs))
+        output: list[ResponseFunctionToolCall | ResponseOutputMessage]
+        if len(outputs) < self.lookups:
+            output = [
+                ResponseFunctionToolCall(
+                    id=f"fc-{len(outputs)}",
+                    call_id=f"call-{len(outputs)}",
+                    name="lookup",
+                    arguments=json.dumps({self.argument: f"k{len(outputs)}"}),
+                    type="function_call",
+                )
+            ]
+        else:
+            if self.fail_on_answer:
+                raise RuntimeError("worker lost")
+            text = ResponseOutputText(text=f"answer: {outputs[-1]}", 
type="output_text", annotations=[])
+            output = [
+                ResponseOutputMessage(
+                    id="msg", role="assistant", status="completed", 
type="message", content=[text]
+                )
+            ]
+        usage = Usage(
+            requests=1, input_tokens=self.usage, output_tokens=self.usage, 
total_tokens=2 * self.usage
+        )
+        return ModelResponse(output=list(output), usage=usage, 
response_id=f"resp-{self.calls}")
+
+    def stream_response(self, *args: Any, **kwargs: Any):
+        raise NotImplementedError
+
+
+class _CountingTool:
+    """A ``lookup`` tool that counts the calls that reached it."""
+
+    def __init__(self, result: str = "42") -> None:
+        self.result = result
+        self.calls = 0
+
+    def tool(self) -> AirflowTool:
+        async def function(arguments: dict[str, Any]) -> ToolResult:
+            self.calls += 1
+            return ToolResult(f"{arguments['key']}={self.result}")
+
+        return AirflowTool(
+            name="lookup",
+            description="Look a key up.",
+            parameters={"type": "object", "properties": {"key": {"type": 
"string"}}},
+            function=function,
+        )
+
+
+class _Provider(ModelProvider):
+    """Resolves every model name to one model."""
+
+    def __init__(self, model: Model) -> None:
+        self.model = model
+
+    def get_model(self, model_name: str | None) -> Model:
+        return self.model
+
+
+class _Report(BaseModel):
+    rows: int
+
+
+def _attempt(storage: MemoryStorage, model: Model, tool: _CountingTool, 
prompt: str = "go", **kwargs: Any):
+    """Run one task attempt: a fresh journal over the storage the attempts 
share."""
+    agent = Agent(name="analyst", model=model, tools=AirflowTools(tool.tool()))
+    journal = DurableJournal(storage, clean_up_after_run=True)
+    with journal_scope(journal):
+        return run_durably_sync(agent, prompt, run_config=NO_TRACING, 
**kwargs), journal
+
+
+class TestRunDurably:
+    def test_a_retry_replays_the_calls_the_failed_attempt_completed(self):
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError, match="worker lost"):
+            _attempt(storage, _ScriptedModel(fail_on_answer=True), tool)
+        assert tool.calls == 1
+
+        retry_model = _ScriptedModel()
+        result, journal = _attempt(storage, retry_model, tool)
+
+        assert result.final_output == "answer: k0=42"
+        # The first request and the tool call replay; only the request that 
answers is new.
+        assert retry_model.calls == 1
+        assert tool.calls == 1
+        assert journal.stats.replayed == {"model": 1, "tool": 1}
+
+    def test_every_completed_call_replays_not_only_the_first(self):
+        """A replayed answer must turn into the same next request as the live 
one did."""
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError, match="worker lost"):
+            _attempt(storage, _ScriptedModel(lookups=3, fail_on_answer=True), 
tool)
+
+        retry_model = _ScriptedModel(lookups=3)
+        _, journal = _attempt(storage, retry_model, tool)
+
+        assert retry_model.calls == 1
+        assert tool.calls == 3
+        assert journal.stats.replayed == {"model": 3, "tool": 3}
+
+    def test_the_sdks_generated_prompt_cache_key_does_not_stop_a_replay(self):
+        """The runner adds a key of its own to every request of a model that 
accepts one."""
+
+        class _CachingModel(_ScriptedModel):
+            def _supports_default_prompt_cache_key(self) -> bool:
+                return True
+
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError):
+            _attempt(storage, _CachingModel(fail_on_answer=True), tool)
+
+        retry_model = _CachingModel()
+        _attempt(storage, retry_model, tool)
+
+        assert retry_model.calls == 1
+
+    def test_a_replayed_model_call_reports_no_usage(self):
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError):
+            _attempt(storage, _ScriptedModel(fail_on_answer=True, usage=7), 
tool)
+
+        result, _ = _attempt(storage, _ScriptedModel(usage=7), tool)
+
+        assert result.context_wrapper.usage.input_tokens == 7
+
+    def test_a_successful_run_deletes_what_it_recorded(self):
+        storage, tool = MemoryStorage(), _CountingTool()
+
+        _attempt(storage, _ScriptedModel(), tool)
+
+        assert storage.entries == {}
+
+    def test_a_different_prompt_runs_every_call_again(self):
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError):
+            _attempt(storage, _ScriptedModel(fail_on_answer=True), tool, 
prompt="first question")
+
+        retry_model = _ScriptedModel()
+        _attempt(storage, retry_model, tool, prompt="second question")
+
+        assert retry_model.calls == 2
+        assert tool.calls == 2
+
+    def test_a_run_the_model_failed_asks_the_model_again(self):
+        """Running out of turns comes from the model's answers, which would 
replay into the same failure."""
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(MaxTurnsExceeded):
+            _attempt(storage, _ScriptedModel(lookups=3), tool, max_turns=2)
+        assert tool.calls == 2
+
+        retry_model = _ScriptedModel(lookups=1)
+        result, _ = _attempt(storage, retry_model, tool, max_turns=5)
+
+        # The calls before the model's last request replay; that request is 
asked again, so
+        # the retry answers after the first lookup instead of replaying the 
second.
+        assert result.final_output == "answer: k0=42"
+        assert retry_model.calls == 1
+        assert tool.calls == 2
+
+    def test_a_model_resolved_by_name_is_recorded(self):
+        storage, tool = MemoryStorage(), _CountingTool()
+
+        def attempt(model: _ScriptedModel) -> None:
+            agent = Agent(name="analyst", model="scripted", 
tools=AirflowTools(tool.tool()))
+            config = RunConfig(tracing_disabled=True, 
model_provider=_Provider(model))
+            with journal_scope(DurableJournal(storage, 
clean_up_after_run=True)):
+                run_durably_sync(agent, "go", run_config=config)
+
+        with pytest.raises(RuntimeError):
+            attempt(_ScriptedModel(fail_on_answer=True))
+        retry_model = _ScriptedModel()
+        attempt(retry_model)
+
+        assert retry_model.calls == 1
+
+    def test_an_agent_used_as_a_tool_replays_as_one_call(self):
+        """Its own model calls run inside the outer tool call and must not 
take the outer run's positions."""
+        storage, tool = MemoryStorage(), _CountingTool()
+
+        def attempt(outer: _ScriptedModel, inner: _ScriptedModel) -> None:
+            specialist = Agent(name="specialist", model="inner", 
tools=AirflowTools(tool.tool()))
+            lead = Agent(
+                name="lead",
+                model=outer,
+                tools=[specialist.as_tool("lookup", "Ask the specialist.")],
+            )
+            config = RunConfig(tracing_disabled=True, 
model_provider=_Provider(inner))
+            with journal_scope(DurableJournal(storage, 
clean_up_after_run=True)):
+                run_durably_sync(lead, "go", run_config=config)
+
+        # ``as_tool`` takes the question for the specialist as ``input``.
+        with pytest.raises(RuntimeError):
+            attempt(_ScriptedModel(lookups=2, fail_on_answer=True, 
argument="input"), _ScriptedModel())
+        assert tool.calls == 2
+        outer, inner = _ScriptedModel(lookups=2, argument="input"), 
_ScriptedModel()
+        attempt(outer, inner)
+
+        # Both outer requests for the specialist and both of its answers 
replay.
+        assert outer.calls == 1
+        assert inner.calls == 0
+
+    @pytest.mark.enable_redact
+    def test_a_secret_in_a_tool_result_is_not_recorded(self, 
registered_secret):
+        """Any function tool, not only the Airflow tools, whose results are 
masked already."""
+
+        @function_tool
+        def lookup(key: str) -> str:
+            """Look a key up."""
+            return f"{key}={registered_secret}"
+
+        agent = Agent(name="analyst", 
model=_ScriptedModel(fail_on_answer=True), tools=[lookup])
+        storage = MemoryStorage()
+
+        with journal_scope(DurableJournal(storage, clean_up_after_run=True)), 
pytest.raises(RuntimeError):
+            run_durably_sync(agent, "go", run_config=NO_TRACING)
+
+        (recorded,) = [step for step in storage.steps() if step["name"] == 
"tool:lookup"]
+        assert recorded["payload"] == "k0=***"
+        assert registered_secret not in json.dumps(storage.entries)
+
+    @pytest.mark.parametrize(
+        "result",
+        [
+            pytest.param(_Report(rows=3), id="pydantic-model"),
+            pytest.param(("orders", 3), id="tuple"),
+            pytest.param(datetime.datetime(2026, 10, 14, 9, 30), 
id="datetime"),
+            pytest.param({"rows": 3}, id="dict"),
+            pytest.param(ToolOutputText(text="3 rows"), id="structured"),
+            pytest.param(
+                [ToolOutputText(text="3 rows"), ToolOutputText(text="2 
orders")], id="structured-list"
+            ),
+            pytest.param({"type": "text", "text": "3 rows"}, id="typed-dict"),
+        ],
+    )
+    def test_a_tool_result_replays_as_the_model_saw_it(self, result):
+        """The SDK sends most results as str(); a replay must turn into the 
same next request."""
+
+        @function_tool
+        def lookup(key: str) -> Any:
+            """Look a key up."""
+            return result
+
+        storage = MemoryStorage()
+
+        def attempt(model: _ScriptedModel) -> DurableJournal:
+            agent = Agent(name="analyst", model=model, tools=[lookup])
+            journal = DurableJournal(storage, clean_up_after_run=True)
+            with journal_scope(journal):
+                run_durably_sync(agent, "go", run_config=NO_TRACING)
+            return journal
+
+        with pytest.raises(RuntimeError):
+            attempt(_ScriptedModel(lookups=2, fail_on_answer=True))
+        journal = attempt(_ScriptedModel(lookups=2))
+
+        assert journal.stats.replayed == {"model": 2, "tool": 2}
+
+    @pytest.mark.parametrize(
+        "change",
+        [
+            pytest.param({"instructions": "Answer in French."}, 
id="instructions"),
+            pytest.param({"model_settings": ModelSettings(temperature=0.1)}, 
id="settings"),
+            pytest.param({"output_type": _Report}, id="output-type"),
+            pytest.param({"handoffs": [Agent(name="auditor")]}, id="handoffs"),
+            pytest.param({"tools": []}, id="tools"),
+        ],
+    )
+    def test_a_request_that_changed_between_attempts_runs_live(self, change):
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError):
+            _attempt(storage, _ScriptedModel(fail_on_answer=True), tool)
+
+        retry_model = _ScriptedModel()
+        agent = Agent(name="analyst", model=retry_model, 
tools=AirflowTools(tool.tool())).clone(**change)
+        # Some changes make the scripted answers invalid for the agent; only 
the first request matters.
+        with journal_scope(DurableJournal(storage, clean_up_after_run=True)), 
contextlib.suppress(Exception):
+            run_durably_sync(agent, "go", run_config=NO_TRACING)
+
+        # The first request reached the model, rather than replaying the 
failed try's answer.
+        assert retry_model.tool_results_seen[0] == 0
+
+    def test_a_request_the_sdk_retried_keeps_its_position(self):
+        """The SDK's model retry makes the same call again; the next try must 
still line up."""
+        settings = ModelSettings(
+            retry=ModelRetrySettings(
+                max_retries=1,
+                backoff=ModelRetryBackoffSettings(initial_delay=0, 
max_delay=0, jitter=False),
+                policy=lambda context: True,
+            )
+        )
+        storage, tool = MemoryStorage(), _CountingTool()
+
+        def attempt(model: _ScriptedModel) -> DurableJournal:
+            agent = Agent(
+                name="analyst", model=model, tools=AirflowTools(tool.tool()), 
model_settings=settings
+            )
+            journal = DurableJournal(storage, clean_up_after_run=True)
+            with journal_scope(journal):
+                run_durably_sync(agent, "go", run_config=NO_TRACING)
+            return journal
+
+        with pytest.raises(RuntimeError, match="worker lost"):
+            attempt(_ScriptedModel(fail_first_request=True, 
fail_on_answer=True))
+        journal = attempt(_ScriptedModel())
+
+        assert journal.stats.replayed == {"model": 1, "tool": 1}
+
+    def 
test_a_tool_whose_approval_depends_on_its_arguments_is_left_to_the_sdk(self):
+        async def policy(context, arguments, call_id) -> bool:
+            return arguments.get("amount", 0) > 100
+
+        @function_tool(needs_approval=policy)
+        def pay(amount: int = 1000) -> str:
+            """Pay an amount."""
+            return "paid"
+
+        assert _get_durable_tool(pay, 
DurableJournal(MemoryStorage()).start_run()) is pay
+
+    @pytest.mark.parametrize(
+        ("option", "value"),
+        [
+            ("previous_response_id", "resp_1"),
+            ("conversation_id", "conv_1"),
+            ("auto_previous_response_id", True),
+        ],
+    )
+    def test_refuses_a_conversation_kept_on_openais_servers(self, option, 
value):
+        with pytest.raises(ValueError, match=option):
+            _attempt(MemoryStorage(), _ScriptedModel(), _CountingTool(), 
**{option: value})
+
+    def 
test_a_recorded_answer_that_cannot_be_read_runs_live_with_every_call_after_it(self):
+        storage, tool = MemoryStorage(), _CountingTool()
+        with pytest.raises(RuntimeError):
+            _attempt(storage, _ScriptedModel(fail_on_answer=True), tool)
+        storage.entries[build_step_key(0, 0)]["payload"]["output"] = [{"type": 
"not an output item"}]
+
+        retry_model = _ScriptedModel()
+        _, journal = _attempt(storage, retry_model, tool)
+
+        assert retry_model.calls == 2
+        assert tool.calls == 2
+        assert journal.stats.replayed == {"model": 0}
+
+    @pytest.mark.parametrize(
+        "failure_error_function",
+        [
+            pytest.param({}, id="default-formatter"),
+            pytest.param(
+                {"failure_error_function": lambda ctx, error: f"lookup failed: 
{error}"}, id="custom"
+            ),
+        ],
+    )
+    def test_a_tool_call_that_failed_runs_again_on_retry(self, 
failure_error_function):
+        """The SDK hands the model the error as text; that text must not 
replay as the tool's result."""
+        calls = []
+
+        @function_tool(**failure_error_function)
+        def lookup(key: str) -> str:
+            """Look a key up."""
+            calls.append(key)
+            if len(calls) == 1:
+                raise ConnectionError("warehouse unreachable")
+            return f"{key}=42"
+
+        storage = MemoryStorage()
+
+        def attempt(model: _ScriptedModel) -> DurableJournal:
+            journal = DurableJournal(storage, clean_up_after_run=True)
+            with journal_scope(journal):
+                run_durably_sync(
+                    Agent(name="analyst", model=model, tools=[lookup]), "go", 
run_config=NO_TRACING
+                )
+            return journal
+
+        with pytest.raises(RuntimeError, match="worker lost"):
+            attempt(_ScriptedModel(fail_on_answer=True))
+        retry_model = _ScriptedModel()
+        attempt(retry_model)
+
+        assert len(calls) == 2
+        assert retry_model.calls == 1
+
+    def 
test_a_model_behavior_error_a_tool_raises_reruns_the_tool_and_replays_the_answer_before_it(self):
+        calls = []
+
+        @function_tool(failure_error_function=None)
+        def lookup(key: str) -> str:
+            """Look a key up."""
+            calls.append(key)
+            if len(calls) == 1:
+                raise ModelBehaviorError("lookup was called with a key that 
does not exist")
+            return f"{key}=42"
+
+        storage = MemoryStorage()
+
+        def attempt(model: _ScriptedModel) -> None:
+            with journal_scope(DurableJournal(storage, 
clean_up_after_run=True)):
+                run_durably_sync(
+                    Agent(name="analyst", model=model, tools=[lookup]), "go", 
run_config=NO_TRACING
+                )
+
+        with pytest.raises(ModelBehaviorError):
+            attempt(_ScriptedModel())
+        retry_model = _ScriptedModel()
+        attempt(retry_model)
+
+        assert len(calls) == 2
+        # The answer that asked for the lookup replays; only the final answer 
is asked for.
+        assert retry_model.calls == 1
+
+    def test_a_run_with_a_session_runs_live_on_retry(self, tmp_path):
+        """The session already holds the failed try's prompt, so the first 
request differs."""
+        storage, tool = MemoryStorage(), _CountingTool()
+        session = SQLiteSession("analyst", str(tmp_path / "session.db"))
+        with pytest.raises(RuntimeError):
+            _attempt(storage, _ScriptedModel(fail_on_answer=True), tool, 
session=session)
+
+        _, journal = _attempt(storage, _ScriptedModel(), tool, session=session)
+
+        assert journal.stats.replayed == {}
+
+    def test_outside_a_task_it_runs_the_agent_as_runner_run_does(self):
+        model, tool = _ScriptedModel(), _CountingTool()
+        agent = Agent(name="analyst", model=model, 
tools=AirflowTools(tool.tool()))
+
+        result = run_durably_sync(agent, "go", run_config=NO_TRACING)
+
+        assert result.final_output == "answer: k0=42"
+        assert model.calls == 2

Reply via email to