This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch openai-agents-durable
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/openai-agents-durable by this 
push:
     new 7715f05b85d Run a failed OpenAI Agents tool call again on retry 
instead of replaying its error
7715f05b85d is described below

commit 7715f05b85d226dcca2d4d68ee05755981bd4914
Author: Kaxil Naik <[email protected]>
AuthorDate: Fri Oct 16 16:10:59 2026 +0100

    Run a failed OpenAI Agents tool call again on retry instead of replaying 
its error
    
    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.
---
 .../common/ai/docs/frameworks/openai_agents.rst    |  72 ++++++-----
 .../providers/common/ai/durable/openai_agents.py   | 133 +++++++++++++--------
 .../unit/common/ai/durable/test_openai_agents.py   | 116 +++++++++++++++++-
 3 files changed, 235 insertions(+), 86 deletions(-)

diff --git a/providers/common/ai/docs/frameworks/openai_agents.rst 
b/providers/common/ai/docs/frameworks/openai_agents.rst
index 1fcc1c17c56..a704a4298d9 100644
--- a/providers/common/ai/docs/frameworks/openai_agents.rst
+++ b/providers/common/ai/docs/frameworks/openai_agents.rst
@@ -152,7 +152,9 @@ again from the prompt: every model call is paid for again, 
and every tool call h
 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``:
+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
@@ -161,40 +163,52 @@ instead. They take the same arguments as 
``Runner.run_sync`` and ``Runner.run``:
 
 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 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. From the first call that differs, that call and every call after it run 
live. Once
-the run succeeds, what was recorded is deleted, so clearing the task later 
starts over.
+: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.
 
-A retry runs some work again:
+What a retry replays
+^^^^^^^^^^^^^^^^^^^^
 
-- A call that was running when the try failed runs again, so tools that change 
data
-  must still be safe to repeat.
+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. A ``ModelBehaviorError`` that a tool raises, such as 
arguments
-  that fail validation with ``failure_error_function=None``, counts as the 
tool's failure
-  instead: the tool call runs again, but the answer that asked for it replays.
-- 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.
-- These are not recorded and run live on every try: 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 the
-  arguments it approved only through its own invoker; the function tools of an 
agent
-  reached through ``handoff()``, and its model unless that is given by name or 
left to
-  the default; and a streamed run. An agent used through ``as_tool`` is 
recorded as one
-  call.
-- A ``session`` makes every retry run live: the SDK adds the failed try's 
prompt to it
+  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.
-
-Recorded tool results pass through Airflow's secret masker before they are 
saved, and a
-replayed model call reports no token usage. ``run_durably`` refuses
-``previous_response_id``, ``conversation_id`` and 
``auto_previous_response_id``: with them
-OpenAI keeps the conversation, and a replayed call never reaches it. Outside a 
task there
-is nothing to replay into, and it runs the agent as ``Runner.run`` does.
+
+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``
 ----------------------------------
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
index bc0af085627..1224565c184 100644
--- 
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
@@ -53,6 +53,11 @@ try:
         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)
 
@@ -91,6 +96,10 @@ _MODEL_AT_FAULT = (
 # 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")
 
@@ -133,9 +142,11 @@ async def run_durably(
     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 
unless that is
-    given by name or left to the default, and streamed runs. A replayed model 
call reports
-    no usage, since it costs nothing.
+    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.
@@ -150,8 +161,7 @@ async def run_durably(
         ``auto_previous_response_id`` is passed: a replayed call never reaches 
the
         conversation OpenAI keeps for them.
     """
-    journal = current_journal()
-    if journal is None:
+    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(
@@ -163,7 +173,7 @@ async def run_durably(
     config.model_provider = _DurableModelProvider(config.model_provider, 
durable_run)
     if isinstance(config.model, Model):
         config.model = _DurableModel(config.model, durable_run)
-    agent = _durable_agent(starting_agent, 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:
@@ -185,32 +195,46 @@ def run_durably_sync(
     return run_coroutine_sync(run_durably(starting_agent, input, 
run_config=run_config, **kwargs))
 
 
-def _durable_agent(agent: Agent[Any], durable_run: DurableRun, seen: dict[int, 
Agent[Any]]) -> Agent[Any]:
+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.clone()
+    durable_agent = agent.clone()
     # Recorded before the handoffs are copied, so agents that hand off to each 
other copy once.
-    seen[id(agent)] = durable
+    seen[id(agent)] = durable_agent
     if isinstance(agent.model, Model):
-        durable.model = _DurableModel(agent.model, durable_run)
-    durable.tools = [_durable_tool(tool, durable_run) for tool in agent.tools]
-    durable.handoffs = [
-        _durable_agent(target, durable_run, seen) if isinstance(target, Agent) 
else target
+        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
+    return durable_agent
 
 
-def _durable_tool(tool: Tool, durable_run: DurableRun) -> Tool:
+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 _inside_a_call(durable_run):
+        if _is_inside_a_call(durable_run):
             return await invoke(ctx, arguments)
         try:
             parsed = json.loads(arguments) if arguments else {}
@@ -222,19 +246,26 @@ def _durable_tool(tool: Tool, durable_run: DurableRun) -> 
Tool:
             mask_secrets(parsed if isinstance(parsed, dict) else {"arguments": 
parsed}),
             ctx.tool_call_id,
         )
-        return await durable_run.run(
-            f"tool:{tool.name}",
-            kind="tool",
-            fingerprint=fingerprint,
-            body=lambda: invoke(ctx, arguments),
+        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.
-            to_record=lambda output: mask_secrets(_as_sent(output)),
-        )
+            step.record(mask_secrets(_as_sent(output)))
+        return output
 
-    # A shallow copy, not dataclasses.replace: the tool's schema is already 
prepared.
-    durable = copy.copy(tool)
-    durable.on_invoke_tool = on_invoke_tool
-    return durable
+    durable_tool.on_invoke_tool = on_invoke_tool
+    return durable_tool
 
 
 def _as_sent(output: Any) -> Any:
@@ -257,7 +288,7 @@ def _as_sent(output: Any) -> Any:
     return str(output)
 
 
-def _inside_a_call(durable_run: DurableRun) -> bool:
+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``.
 
@@ -272,24 +303,24 @@ 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] = {}
+        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]
+        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._model = model
-        self._durable_run = durable_run
+        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: JournalStep | None = None
+        self.failed_step: JournalStep | None = None
 
     async def get_response(
         self,
@@ -302,14 +333,14 @@ class _DurableModel(Model):
         tracing: ModelTracing,
         **kwargs: Any,
     ) -> ModelResponse:
-        if _inside_a_call(self._durable_run):
-            return await self._model.get_response(
+        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._model, system_instructions, input, model_settings, tools, 
output_schema, handoffs, kwargs
+            self.wrapped, system_instructions, input, model_settings, tools, 
output_schema, handoffs, kwargs
         )
-        step = self._retried(fingerprint) or self._durable_run.claim(
+        step = self._retried(fingerprint) or self.durable_run.claim(
             "model", kind="model", fingerprint=fingerprint
         )
         if step.replayed:
@@ -320,17 +351,17 @@ class _DurableModel(Model):
                     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}")
+                self.durable_run.reject(step, reason=f"the recorded model 
response cannot be read: {error}")
 
         async def call() -> ModelResponse:
-            return await self._model.get_response(
+            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
+            self.failed_step = step
             raise
 
     def _retried(self, fingerprint: str | None) -> JournalStep | None:
@@ -340,10 +371,10 @@ class _DurableModel(Model):
         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 = self._failed, None
+        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
+        return failed if self.durable_run.position == failed.position + 1 else 
None
 
     def stream_response(
         self,
@@ -357,23 +388,23 @@ class _DurableModel(Model):
         **kwargs: Any,
     ) -> AsyncIterator[TResponseStreamEvent]:
         # A streamed response is not recorded: it reaches the caller event by 
event.
-        return self._model.stream_response(
+        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._model, "_supports_default_prompt_cache_key", 
None)
+        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._model.get_retry_advice(request)
+        return self.wrapped.get_retry_advice(request)
 
     async def _cleanup_on_run_end(self, owner: object) -> None:
-        await self._model._cleanup_on_run_end(owner)
+        await self.wrapped._cleanup_on_run_end(owner)
 
     async def close(self) -> None:
-        await self._model.close()
+        await self.wrapped.close()
 
 
 def _record_response(response: ModelResponse) -> dict[str, Any]:
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
index f1bffe35775..30baff77db8 100644
--- 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
@@ -32,17 +32,19 @@ from agents import (
     ModelResponse,
     ModelSettings,
     RunConfig,
+    SQLiteSession,
     ToolOutputText,
     Usage,
     function_tool,
 )
-from agents.exceptions import MaxTurnsExceeded
+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 _durable_tool, 
run_durably_sync
+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
 
@@ -316,6 +318,10 @@ class TestRunDurably:
             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):
@@ -400,12 +406,110 @@ class TestRunDurably:
             """Pay an amount."""
             return "paid"
 
-        assert _durable_tool(pay, DurableJournal(MemoryStorage()).start_run()) 
is pay
+        assert _get_durable_tool(pay, 
DurableJournal(MemoryStorage()).start_run()) is pay
 
-    @pytest.mark.parametrize("option", ["previous_response_id", 
"conversation_id"])
-    def test_refuses_a_conversation_kept_on_openais_servers(self, option):
+    @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: "resp_1"})
+            _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()

Reply via email to