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 1fe65f5f13a Keep parallel tool results replayable across retries in
OpenAI Agents durable runs (#76015)
1fe65f5f13a is described below
commit 1fe65f5f13ae060ac799a598e9c446561c0318fb
Author: Wei Lee <[email protected]>
AuthorDate: Fri Nov 6 08:38:35 2026 +0900
Keep parallel tool results replayable across retries in OpenAI Agents
durable runs (#76015)
Tool calls that run in parallel took their journal positions in the order
they
reached the tool wrapper, which follows how long run hooks, agent hooks and
tool
input guardrails took, not the order the model made the calls. When that
timing
differed between attempts, the positions no longer matched, the run
diverged and
tools that had completed ran again on retry.
The model wrapper now claims one position per function tool call in the
model's
own order and each tool picks up its position by tool call id. Also test the
parallel, handoff, streaming and approval cases.
* Release a durable run's reserved tool steps when it ends and reserve only
for wrapped tools
* Split the _SleepingTools test helper's docstring into a summary and a
description
The single-line docstring ran past the line width; it now opens with a
summary
sentence and keeps the timing, failure and call-count details below it.
* Keep tool calls that share a call id from taking each other's reserved
journal step
A response whose function tool calls repeat a call id, or leave it empty,
used to
have the later reservation overwrite the earlier one, so a tool could pick
up
another call's step and record its result there. A tool now takes a reserved
step only when the step's name and fingerprint are its own, and leaves it
for
the call it belongs to otherwise; a repeated id is reserved once. The SDK
rejects such responses before any tool runs, so this guards the reservation
made before that check.
* Count a reserved tool step as replayed only once its tool takes it
A journal position reserved for a tool call counted as replayed as soon as
it
was reserved, so a call whose tool never ran on the retry (stopped by a tool
input guardrail, for instance) was still counted. The journal now offers
``DurableRun.reserve``, which takes and matches a position like ``claim``
but
leaves the count to ``JournalStep.take``, called when a tool picks its step
up.
``claim`` is unchanged for every other caller, and ``reject`` only
subtracts a
replay it counted.
* Test that only wrapped tools get a reserved journal step
Nothing pinned the marker check in _reserve_tool_steps: widening it to every
FunctionTool left the whole file green. The new test puts a wrapped tool
and a
plain one in the same response and checks that only the wrapped call takes a
position.
* Keep a durable run's reserved tool steps on the run instead of in a
module global
- DurableRun.reserve now takes the response's tool calls, replaces what the
previous response left unclaimed, and keeps the steps on the run.
- DurableRun.claim_reserved hands a tool its reserved step by call id (or a
new position when none matches) and counts the replay.
- A reservation no tool took can no longer be picked up by a later call that
reuses its id.
- Remove _RESERVED_TOOL_STEPS and the cleanup in run_durably.
* Make the reserved-step replacement test fail without the run-level
reservation table
The earlier test also passed when a later response's reservation was skipped
because its call id was already reserved, since the two calls differed in
arguments. Use the same call id and arguments in both responses and check
the executed step lands at the new position.
Drop the test that rejected a reserved step nothing had taken: production
has
no such path, and it read the private reservation table.
* Inline the durable-tool check and rename JournalStep.take to
count_as_replayed
The check had a single caller, and count_as_replayed says what the method
does
rather than reading like a step taking its position.
* Restore the plain form of the durable-tool check
The inlined check wrapped main's condition in a double negation. Put back
main's own line so the diff no longer touches it.
---------
Co-authored-by: Kaxil Naik <[email protected]>
---
.../airflow/providers/common/ai/durable/journal.py | 70 ++-
.../providers/common/ai/durable/openai_agents.py | 64 ++-
.../tests/unit/common/ai/durable/test_journal.py | 76 ++++
.../unit/common/ai/durable/test_openai_agents.py | 486 ++++++++++++++++++++-
4 files changed, 678 insertions(+), 18 deletions(-)
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 ffaf955098e..2d437834e4d 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
@@ -66,6 +66,7 @@ from airflow.providers.common.compat.version_compat import
AIRFLOW_V_3_3_PLUS
if TYPE_CHECKING:
import logging
+ from collections.abc import Sequence
from airflow.providers.common.ai.durable.base import DurableStorageProtocol
from airflow.sdk import Context
@@ -95,9 +96,22 @@ class JournalStep:
replayable: bool = True
replayed: bool = False
payload: Any = None
+ # Whether this step's replay is in ``stats.replayed``; see
:meth:`DurableRun.reserve`.
+ _replay_counted: bool = field(default=False, init=False, repr=False)
# Agent runs started while this step executes; they are numbered under it.
nested_runs: int = field(default=0, repr=False)
+ def count_as_replayed(self) -> None:
+ """
+ Count this step as replayed, once.
+
+ For a step from :meth:`DurableRun.reserve`, which
:meth:`DurableRun.claim_reserved` does;
+ a step from :meth:`DurableRun.claim` is already counted.
+ """
+ if self.replayed and not self._replay_counted:
+ self._replay_counted = True
+ self.durable_run.journal.stats.replayed[self.kind] += 1
+
async def run(
self, body: Callable[[], Awaitable[Any]], *, to_record:
Callable[[Any], Any] | None = None
) -> Any:
@@ -182,8 +196,9 @@ class DurableRun:
One agent run's steps in the journal.
Positions are handed out in the order steps are claimed. A step must be
claimed
- before its first ``await``, so concurrent steps (parallel tool calls) take
their
- positions in the order they were started rather than the order they finish.
+ before its first ``await``, so concurrent steps take their positions in
the order they
+ were started rather than the order they finish. An adapter whose
concurrent steps can
+ be delayed before their claim must claim them itself in a deterministic
order.
Besides its steps, a run keeps one entry about itself: where the last
attempt that
failed stopped being trustworthy, and the furthest position any attempt
reached, so
@@ -215,6 +230,8 @@ class DurableRun:
# 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
+ # Steps ``reserve`` numbered for the latest model response, by tool
call id, until taken.
+ self._reserved: dict[str, JournalStep] = {}
@property
def position(self) -> int:
@@ -245,6 +262,42 @@ class DurableRun:
self._peeked[position] =
self.journal.storage.load_step(build_step_key(self.key, position))
return self._peeked[position]
+ def reserve(self, calls: Sequence[tuple[str, str, str | None]], *, kind:
StepKind) -> None:
+ """
+ Take a position for each of ``calls`` in order, and keep them for
:meth:`claim_reserved`.
+
+ For an adapter that has to number steps before it knows whether they
will run. Each call
+ is ``(call_id, name, fingerprint)``. A replay counts in the stats only
once the step is
+ taken. Replaces what an earlier ``reserve`` left unclaimed, and a call
whose id is
+ already in ``calls`` takes no position. Like :meth:`claim`, call it
before the first ``await``.
+ """
+ reserved: dict[str, JournalStep] = {}
+ for call_id, name, fingerprint in calls:
+ if call_id not in reserved:
+ reserved[call_id] = self._claim(
+ name, kind=kind, fingerprint=fingerprint, replayable=True,
count_replay=False
+ )
+ self._reserved = reserved
+
+ def claim_reserved(
+ self, call_id: str, name: str, *, kind: StepKind, fingerprint: str |
None
+ ) -> JournalStep:
+ """
+ Take the step :meth:`reserve` kept for ``call_id``, counting its
replay.
+
+ A call whose id was not reserved, or whose reservation is already
taken or belongs to
+ another call sharing the id (some non-OpenAI adapters), takes a new
position as
+ :meth:`claim` does. Call it before the first ``await``.
+ """
+ step = self._reserved.pop(call_id, None)
+ if step is not None and (step.name, step.fingerprint) != (name,
fingerprint):
+ self._reserved[call_id] = step
+ step = None
+ if step is None:
+ step = self.claim(name, kind=kind, fingerprint=fingerprint)
+ step.count_as_replayed()
+ return step
+
def claim(
self, name: str, *, kind: StepKind, fingerprint: str | None,
replayable: bool = True
) -> JournalStep:
@@ -261,6 +314,11 @@ class DurableRun:
whose effect Airflow cannot observe. It still takes a position, so
the steps
after it keep theirs, but nothing is looked up or recorded for it.
"""
+ return self._claim(name, kind=kind, fingerprint=fingerprint,
replayable=replayable, count_replay=True)
+
+ def _claim(
+ self, name: str, *, kind: StepKind, fingerprint: str | None,
replayable: bool, count_replay: bool
+ ) -> JournalStep:
position = self._position
self._position += 1
step = JournalStep(self, position, name, kind, fingerprint,
replayable=replayable)
@@ -287,7 +345,9 @@ class DurableRun:
return step
step.replayed = self._previous_replayed = True
step.payload = entry.get("payload")
- self.journal.stats.replayed[kind] += 1
+ if count_replay:
+ step._replay_counted = True
+ self.journal.stats.replayed[kind] += 1
log.debug("Durable: replayed step", position=position, step=name)
return step
self._diverge(
@@ -397,7 +457,9 @@ class DurableRun:
step.replayed = False
step.payload = None
self._previous_replayed = False
- self.journal.stats.replayed[step.kind] -= 1
+ if step._replay_counted:
+ step._replay_counted = False
+ self.journal.stats.replayed[step.kind] -= 1
self._diverge(step.position, step.name, reason=reason)
def _diverge(self, position: int, name: str, *, reason: str) -> None:
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 1224565c184..9f2388c3dd5 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
@@ -58,6 +58,7 @@ try:
resolve_function_tool_failure_error_function,
set_function_tool_failure_error_function,
)
+ from openai.types.responses import ResponseFunctionToolCall
except ImportError as e:
raise AirflowOptionalProviderFeatureException(e)
@@ -103,6 +104,10 @@ _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")
+# Set on the copy of a function tool that reports through the journal, so that
reserving a position
+# skips tools nothing wraps, such as those of MCP servers or of a handoff
agent with a named model.
+_DURABLE_MARKER = "_airflow_durable_tool"
+
async def run_durably(
starting_agent: Agent[Any],
@@ -219,6 +224,7 @@ def _get_durable_tool(tool: Tool, durable_run: DurableRun)
-> Tool:
return tool
# A shallow copy, not dataclasses.replace: the tool's schema is already
prepared.
durable_tool = copy.copy(tool)
+ setattr(durable_tool, _DURABLE_MARKER, True)
invoke = tool.on_invoke_tool
# Exceptions a custom failure_error_function turned into text, by tool
call.
handled: dict[str, Exception] = {}
@@ -236,17 +242,9 @@ def _get_durable_tool(tool: Tool, durable_run: DurableRun)
-> 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)
+ name = f"tool:{tool.name}"
+ fingerprint = _build_tool_fingerprint(tool.name, arguments,
ctx.tool_call_id)
+ step = durable_run.claim_reserved(ctx.tool_call_id, name, kind="tool",
fingerprint=fingerprint)
if step.replayed:
return step.payload
try:
@@ -268,6 +266,19 @@ def _get_durable_tool(tool: Tool, durable_run: DurableRun)
-> Tool:
return durable_tool
+def _build_tool_fingerprint(name: str, arguments: str, call_id: str) -> str |
None:
+ try:
+ parsed = json.loads(arguments) if arguments else {}
+ except json.JSONDecodeError:
+ parsed = arguments
+ return fingerprint_tool_call(
+ 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}),
+ call_id,
+ )
+
+
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.
@@ -345,13 +356,16 @@ class _DurableModel(Model):
)
if step.replayed:
try:
- return ModelResponse(
+ replayed = 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}")
+ else:
+ self._reserve_tool_steps(replayed, tools)
+ return replayed
async def call() -> ModelResponse:
return await self.wrapped.get_response(
@@ -359,10 +373,34 @@ class _DurableModel(Model):
)
try:
- return await step.run(call, to_record=_record_response)
+ response = await step.run(call, to_record=_record_response)
except Exception:
self.failed_step = step
raise
+ self._reserve_tool_steps(response, tools)
+ return response
+
+ def _reserve_tool_steps(self, response: ModelResponse, tools: list[Tool])
-> None:
+ """
+ Claim a position for each of the response's tool calls, in the order
the model made them.
+
+ A call whose tool never runs (held for approval, stopped by a
guardrail) leaves its position
+ empty, which a retry runs live without disturbing the positions after
it. The steps the
+ previous response reserved and no tool took are dropped.
+ """
+ durable_names = {tool.name for tool in tools if getattr(tool,
_DURABLE_MARKER, False)}
+ self.durable_run.reserve(
+ [
+ (
+ item.call_id,
+ f"tool:{item.name}",
+ _build_tool_fingerprint(item.name, item.arguments,
item.call_id),
+ )
+ for item in response.output
+ if isinstance(item, ResponseFunctionToolCall) and item.name in
durable_names
+ ],
+ kind="tool",
+ )
def _retried(self, fingerprint: str | None) -> JournalStep | None:
"""
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 41f70605409..f95c983156c 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
@@ -324,6 +324,82 @@ class TestReject:
assert retry.journal.stats.replayed.total() == 0
+class TestReserve:
+ @staticmethod
+ async def record_tool(memory_storage, *names):
+ first = DurableJournal(memory_storage).start_run()
+ for name in names:
+ await first.run(name, kind="tool", fingerprint=name, body=lambda:
ok("old"))
+
+ @pytest.mark.asyncio
+ async def test_a_reserved_step_counts_as_replayed_only_once_taken(self,
memory_storage):
+ await self.record_tool(memory_storage, "t")
+
+ retry = DurableJournal(memory_storage).start_run()
+ retry.reserve([("c", "t", "t")], kind="tool")
+ assert retry.journal.stats.replayed.total() == 0
+
+ step = retry.claim_reserved("c", "t", kind="tool", fingerprint="t")
+ step.count_as_replayed()
+
+ assert (step.position, step.replayed, step.payload) == (0, True, "old")
+ assert dict(retry.journal.stats.replayed) == {"tool": 1}
+
+ @pytest.mark.asyncio
+ async def test_a_reserved_id_is_claimed_once(self, memory_storage):
+ await self.record_tool(memory_storage, "t")
+
+ retry = DurableJournal(memory_storage).start_run()
+ retry.reserve([("c", "t", "t")], kind="tool")
+ retry.claim_reserved("c", "t", kind="tool", fingerprint="t")
+ again = retry.claim_reserved("c", "t", kind="tool", fingerprint="t")
+
+ assert (again.position, again.replayed) == (1, False)
+ assert dict(retry.journal.stats.replayed) == {"tool": 1}
+
+ @pytest.mark.asyncio
+ async def test_a_later_reserve_drops_the_steps_nothing_took(self,
memory_storage):
+ await self.record_tool(memory_storage, "t")
+
+ retry = DurableJournal(memory_storage).start_run()
+ retry.reserve([("old", "t", "t")], kind="tool")
+ retry.reserve([("new", "u", "u")], kind="tool")
+ stale = retry.claim_reserved("old", "t", kind="tool", fingerprint="t")
+
+ assert (stale.position, stale.replayed) == (2, False)
+
+ @pytest.mark.asyncio
+ async def test_a_reserve_without_calls_drops_the_steps_nothing_took(self,
memory_storage):
+ await self.record_tool(memory_storage, "t")
+
+ retry = DurableJournal(memory_storage).start_run()
+ retry.reserve([("old", "t", "t")], kind="tool")
+ retry.reserve([], kind="tool")
+ stale = retry.claim_reserved("old", "t", kind="tool", fingerprint="t")
+
+ assert (stale.position, stale.replayed) == (1, False)
+
+ @pytest.mark.asyncio
+ async def
test_a_call_that_shares_an_id_leaves_the_reservation_to_the_matching_call(self,
memory_storage):
+ await self.record_tool(memory_storage, "t")
+
+ retry = DurableJournal(memory_storage).start_run()
+ retry.reserve([("c", "t", "t")], kind="tool")
+ other = retry.claim_reserved("c", "t", kind="tool",
fingerprint="other")
+ own = retry.claim_reserved("c", "t", kind="tool", fingerprint="t")
+
+ assert (other.position, other.replayed) == (1, False)
+ assert (own.position, own.replayed) == (0, True)
+
+ def test_duplicate_ids_in_one_reserve_take_one_position(self,
memory_storage):
+ run = DurableJournal(memory_storage).start_run()
+ run.reserve([("c", "t", "a"), ("c", "t", "b"), ("d", "t", "d")],
kind="tool")
+
+ assert run.position == 2
+ assert run.claim_reserved("c", "t", kind="tool",
fingerprint="a").position == 0
+ assert run.claim_reserved("d", "t", kind="tool",
fingerprint="d").position == 1
+
+
class TestFailuresDuringReplay:
@pytest.mark.asyncio
async def
test_an_attempt_killed_while_replaying_leaves_the_earlier_steps_replayable(
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 30baff77db8..da4adcaf7fb 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
@@ -16,10 +16,13 @@
# under the License.
from __future__ import annotations
+import asyncio
+import collections
import contextlib
import datetime
import json
from typing import Any
+from unittest import mock
import pytest
@@ -27,24 +30,36 @@ pytest.importorskip("agents")
from agents import (
Agent,
+ AgentHooks,
Model,
ModelProvider,
ModelResponse,
ModelSettings,
+ ModelTracing,
RunConfig,
+ RunHooks,
SQLiteSession,
+ ToolGuardrailFunctionOutput,
ToolOutputText,
Usage,
function_tool,
+ handoff,
+ tool_input_guardrail,
)
from agents.exceptions import MaxTurnsExceeded, ModelBehaviorError
from agents.retry import ModelRetryBackoffSettings, ModelRetrySettings
+from agents.tool_context import ToolContext
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.durable.openai_agents import (
+ _build_tool_fingerprint,
+ _DurableModel,
+ _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
@@ -139,6 +154,101 @@ class _CountingTool:
)
+class _ParallelModel(Model):
+ """
+ Asks for one ``lookup`` per key in a single response, then answers with
their results.
+
+ The calls get the ids ``call-<key>``. ``fail_on_answer`` makes the request
that would
+ answer raise, as a crashed worker would leave it.
+ """
+
+ def __init__(self, keys: tuple[str, ...] = ("a", "b"), *, fail_on_answer:
bool = False) -> None:
+ self.keys = keys
+ self.fail_on_answer = fail_on_answer
+ self.calls = 0
+
+ async def get_response(self, system_instructions, input, *args: Any,
**kwargs: Any) -> ModelResponse:
+ self.calls += 1
+ outputs = [
+ item["output"]
+ for item in (input if isinstance(input, list) else [])
+ if isinstance(item, dict) and item.get("type") ==
"function_call_output"
+ ]
+ output: list[ResponseFunctionToolCall | ResponseOutputMessage]
+ if not outputs:
+ output = [
+ ResponseFunctionToolCall(
+ id=f"fc-{key}",
+ call_id=f"call-{key}",
+ name="lookup",
+ arguments=json.dumps({"key": key}),
+ type="function_call",
+ )
+ for key in self.keys
+ ]
+ else:
+ if self.fail_on_answer:
+ raise RuntimeError("worker lost")
+ text = ResponseOutputText(text=f"answer: {sorted(outputs)}",
type="output_text", annotations=[])
+ output = [
+ ResponseOutputMessage(
+ id="msg", role="assistant", status="completed",
type="message", content=[text]
+ )
+ ]
+ return ModelResponse(output=list(output), usage=Usage(),
response_id=f"resp-{self.calls}")
+
+ def stream_response(self, *args: Any, **kwargs: Any):
+ raise NotImplementedError
+
+
+class _SleepingTools:
+ """
+ A ``lookup`` tool with controllable timing and failures.
+
+ It sleeps ``body_delays[key]`` seconds, fails for the keys in ``failing``,
and counts the calls per key.
+ """
+
+ def __init__(self, body_delays: dict[str, float] | None = None, failing:
set[str] | None = None) -> None:
+ self.body_delays = body_delays or {}
+ self.failing = failing or set()
+ self.calls: collections.Counter[str] = collections.Counter()
+
+ def tools(self) -> list[Any]:
+ async def function(arguments: dict[str, Any]) -> ToolResult:
+ key = arguments["key"]
+ self.calls[key] += 1
+ await asyncio.sleep(self.body_delays.get(key, 0))
+ if key in self.failing:
+ raise RuntimeError(f"lookup {key} failed")
+ return ToolResult(f"{key}=42")
+
+ return AirflowTools(
+ AirflowTool(
+ name="lookup",
+ description="Look a key up.",
+ parameters={"type": "object", "properties": {"key": {"type":
"string"}}},
+ function=function,
+ )
+ )
+
+
+class _TransferModel(Model):
+ """Hands the run over to the ``specialist`` agent on its first request."""
+
+ async def get_response(self, system_instructions, input, *args: Any,
**kwargs: Any) -> ModelResponse:
+ call = ResponseFunctionToolCall(
+ id="fc-transfer",
+ call_id="call-transfer",
+ name="transfer_to_specialist",
+ arguments="{}",
+ type="function_call",
+ )
+ return ModelResponse(output=[call], usage=Usage(),
response_id="resp-transfer")
+
+ def stream_response(self, *args: Any, **kwargs: Any):
+ raise NotImplementedError
+
+
class _Provider(ModelProvider):
"""Resolves every model name to one model."""
@@ -519,3 +629,377 @@ class TestRunDurably:
assert result.final_output == "answer: k0=42"
assert model.calls == 2
+
+
+class _SlowRunHooks(RunHooks):
+ """Makes every tool call wait ``delays[call id]`` seconds before it is
invoked."""
+
+ def __init__(self, delays: dict[str, float]) -> None:
+ self.delays = delays
+
+ async def on_tool_start(self, context, agent, tool) -> None:
+ await asyncio.sleep(self.delays[context.tool_call_id])
+
+
+class _SlowAgentHooks(AgentHooks):
+ """Makes every tool call of the agent wait ``delays[call id]`` seconds
before it is invoked."""
+
+ def __init__(self, delays: dict[str, float]) -> None:
+ self.delays = delays
+
+ async def on_tool_start(self, context, agent, tool) -> None:
+ await asyncio.sleep(self.delays[context.tool_call_id])
+
+
+def _attempt_parallel(
+ storage: MemoryStorage,
+ model: Model,
+ tools: _SleepingTools,
+ *,
+ agent_hooks: AgentHooks | None = None,
+ guardrail_delays: dict[str, float] | None = None,
+ rejected_calls: set[str] | None = None,
+ **kwargs: Any,
+):
+ """Run one attempt of an agent whose model asks for several tools at
once."""
+ function_tools = tools.tools()
+ if guardrail_delays is not None or rejected_calls is not None:
+
+ @tool_input_guardrail
+ async def wait(data):
+ await asyncio.sleep((guardrail_delays or
{}).get(data.context.tool_call_id, 0))
+ if rejected_calls and data.context.tool_call_id in rejected_calls:
+ return ToolGuardrailFunctionOutput.reject_content("stopped by
a guardrail")
+ return ToolGuardrailFunctionOutput.allow()
+
+ for function_tool_ in function_tools:
+ function_tool_.tool_input_guardrails = [wait]
+ agent = Agent(name="analyst", model=model, tools=function_tools,
hooks=agent_hooks)
+ journal = DurableJournal(storage, clean_up_after_run=True)
+ with journal_scope(journal):
+ return run_durably_sync(agent, "go", run_config=NO_TRACING, **kwargs),
journal
+
+
+class TestParallelToolCalls:
+ def
test_parallel_tool_calls_replay_when_their_bodies_finish_in_a_different_order(self):
+ """
+ Characterizes the documented claim that parallel calls keep the order
they started in.
+
+ The model's response reserves a position for each tool call, so how
long a tool body takes does not matter.
+ """
+ storage = MemoryStorage()
+ tools = _SleepingTools({"a": 0.05, "b": 0.01})
+ with pytest.raises(RuntimeError, match="worker lost"):
+ _attempt_parallel(storage, _ParallelModel(fail_on_answer=True),
tools)
+ assert tools.calls == {"a": 1, "b": 1}
+
+ tools.body_delays = {"a": 0.01, "b": 0.05}
+ retry_model = _ParallelModel()
+ _, journal = _attempt_parallel(storage, retry_model, tools)
+
+ assert journal.stats.replayed == {"model": 1, "tool": 2}
+ assert tools.calls == {"a": 1, "b": 1}
+ assert retry_model.calls == 1
+
+ def test_only_the_parallel_tool_call_that_failed_runs_again_on_retry(self):
+ storage = MemoryStorage()
+ tools = _SleepingTools(failing={"b"})
+ with pytest.raises(Exception, match="lookup b failed"):
+ _attempt_parallel(storage, _ParallelModel(("a", "b", "c")), tools)
+ assert tools.calls == {"a": 1, "b": 1, "c": 1}
+
+ tools.failing = set()
+ retry_model = _ParallelModel(("a", "b", "c"))
+ _, journal = _attempt_parallel(storage, retry_model, tools)
+
+ assert tools.calls == {"a": 1, "b": 2, "c": 1}
+ assert journal.stats.replayed == {"model": 1, "tool": 2}
+ assert retry_model.calls == 1
+
+ def
test_a_replayed_tool_call_a_guardrail_stops_is_not_counted_as_replayed(self):
+ storage = MemoryStorage()
+ tools = _SleepingTools()
+ rejected: set[str] = set()
+ with pytest.raises(RuntimeError, match="worker lost"):
+ _attempt_parallel(storage, _ParallelModel(fail_on_answer=True),
tools, rejected_calls=rejected)
+ assert tools.calls == {"a": 1, "b": 1}
+
+ rejected.add("call-b")
+ retry_model = _ParallelModel()
+ _, journal = _attempt_parallel(storage, retry_model, tools,
rejected_calls=rejected)
+
+ assert journal.stats.replayed == {"model": 1, "tool": 1}
+ assert tools.calls == {"a": 1, "b": 1}
+ assert retry_model.calls == 1
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("call_id", ["call-shared", ""],
ids=["duplicate-id", "empty-id"])
+ async def
test_tool_calls_sharing_a_call_id_each_replay_their_own_result(self, call_id):
+ """The SDK rejects a repeated id itself, but a model that emitted one
must not make the calls trade steps."""
+ storage = MemoryStorage()
+ tools = _SleepingTools()
+ response = ModelResponse(
+ output=[
+ ResponseFunctionToolCall(
+ id=f"fc-{key}",
+ call_id=call_id,
+ name="lookup",
+ arguments=json.dumps({"key": key}),
+ type="function_call",
+ )
+ for key in ("a", "b")
+ ],
+ usage=Usage(),
+ response_id="resp",
+ )
+
+ async def attempt(order: tuple[str, ...]) -> tuple[dict[str, Any],
DurableJournal]:
+ journal = DurableJournal(storage)
+ durable_run = journal.start_run()
+ durable_tools = [_get_durable_tool(tool, durable_run) for tool in
tools.tools()]
+ _DurableModel(mock.create_autospec(Model, instance=True),
durable_run)._reserve_tool_steps(
+ response, durable_tools
+ )
+ outputs = {}
+ for key in order:
+ context = ToolContext(
+ context=None,
+ usage=Usage(),
+ tool_name="lookup",
+ tool_call_id=call_id,
+ tool_arguments=json.dumps({"key": key}),
+ )
+ outputs[key] = await durable_tools[0].on_invoke_tool(context,
json.dumps({"key": key}))
+ return outputs, journal
+
+ await attempt(("a", "b"))
+ assert tools.calls == {"a": 1, "b": 1}
+
+ outputs, journal = await attempt(("b", "a"))
+
+ assert tools.calls == {"a": 1, "b": 1}
+ assert outputs == {"a": "a=42", "b": "b=42"}
+ assert journal.stats.replayed["tool"] == 2
+
+ def test_only_wrapped_tools_get_a_reserved_step(self):
+ """
+ A tool the model is handed but the durable run does not wrap never
takes a reserved step.
+
+ MCP tools and a handoff agent's tools reach the model this way, so
reserving for them would leave
+ positions nobody takes.
+ """
+
+ @function_tool
+ def plain_lookup(key: str) -> str:
+ """Look a key up."""
+ return key
+
+ journal = DurableJournal(MemoryStorage())
+ durable_run = journal.start_run()
+ durable_tool = _get_durable_tool(_SleepingTools().tools()[0],
durable_run)
+ fingerprint = _build_tool_fingerprint("lookup", json.dumps({"key":
"a"}), "call-durable")
+ response = ModelResponse(
+ output=[
+ ResponseFunctionToolCall(
+ id=f"fc-{call_id}",
+ call_id=call_id,
+ name=name,
+ arguments=json.dumps({"key": "a"}),
+ type="function_call",
+ )
+ for call_id, name in (("call-durable", "lookup"),
("call-plain", "plain_lookup"))
+ ],
+ usage=Usage(),
+ response_id="resp",
+ )
+
+ _DurableModel(mock.create_autospec(Model, instance=True),
durable_run)._reserve_tool_steps(
+ response, [durable_tool, plain_lookup]
+ )
+
+ assert durable_run.position == 1
+ durable = durable_run.claim_reserved(
+ "call-durable", "tool:lookup", kind="tool", fingerprint=fingerprint
+ )
+ plain = durable_run.claim_reserved("call-plain", "tool:plain_lookup",
kind="tool", fingerprint=None)
+ assert (durable.position, plain.position) == (0, 1)
+
+ @pytest.mark.asyncio
+ async def
test_a_later_response_reserves_a_new_step_for_a_call_with_the_same_id_and_arguments(self):
+ storage = MemoryStorage()
+ tools = _SleepingTools()
+ response = ModelResponse(
+ output=[
+ ResponseFunctionToolCall(
+ id="fc-x",
+ call_id="call-x",
+ name="lookup",
+ arguments=json.dumps({"key": "a"}),
+ type="function_call",
+ )
+ ],
+ usage=Usage(),
+ response_id="resp",
+ )
+ durable_run = DurableJournal(storage).start_run()
+ durable_tools = [_get_durable_tool(tool, durable_run) for tool in
tools.tools()]
+ model = _DurableModel(mock.create_autospec(Model, instance=True),
durable_run)
+ # No tool takes the first response's call; only the second response's
is invoked.
+ model._reserve_tool_steps(response, durable_tools)
+ model._reserve_tool_steps(response, durable_tools)
+ context = ToolContext(
+ context=None,
+ usage=Usage(),
+ tool_name="lookup",
+ tool_call_id="call-x",
+ tool_arguments=json.dumps({"key": "a"}),
+ )
+
+ await durable_tools[0].on_invoke_tool(context, json.dumps({"key":
"a"}))
+
+ assert build_step_key(durable_run.key, 1) in storage.entries
+ assert build_step_key(durable_run.key, 0) not in storage.entries
+
+ @pytest.mark.parametrize("source", ["run-hook", "agent-hook",
"tool-input-guardrail"])
+ @pytest.mark.parametrize(
+ "first_attempt_delays",
+ [{"call-a": 0.01, "call-b": 0.1}, {"call-a": 0.1, "call-b": 0.01}],
+ ids=["a-first", "b-first"],
+ )
+ def
test_parallel_tool_calls_replay_when_what_runs_before_them_takes_a_different_time(
+ self, source, first_attempt_delays
+ ):
+ """A hook or guardrail the SDK awaits before invoking a tool can let a
later call claim first."""
+ storage = MemoryStorage()
+ tools = _SleepingTools()
+
+ def attempt(model: Model, delays: dict[str, float]):
+ kwargs: dict[str, Any] = {}
+ if source == "run-hook":
+ kwargs["hooks"] = _SlowRunHooks(delays)
+ elif source == "agent-hook":
+ kwargs["agent_hooks"] = _SlowAgentHooks(delays)
+ else:
+ kwargs["guardrail_delays"] = delays
+ return _attempt_parallel(storage, model, tools, **kwargs)
+
+ with pytest.raises(RuntimeError, match="worker lost"):
+ attempt(_ParallelModel(fail_on_answer=True), first_attempt_delays)
+ assert tools.calls == {"a": 1, "b": 1}
+
+ _, journal = attempt(_ParallelModel(), {"call-a": 0.1, "call-b": 0.01})
+
+ assert journal.stats.replayed == {"model": 1, "tool": 2}
+ assert tools.calls == {"a": 1, "b": 1}
+
+
+class TestDurableExceptions:
+ def test_a_static_handoff_agent_replays_its_calls(self):
+ storage, tool = MemoryStorage(), _CountingTool()
+
+ def attempt(specialist_model: _ScriptedModel) -> DurableJournal:
+ specialist = Agent(name="specialist", model=specialist_model,
tools=AirflowTools(tool.tool()))
+ lead = Agent(name="lead", model=_TransferModel(),
handoffs=[specialist])
+ journal = DurableJournal(storage, clean_up_after_run=True)
+ with journal_scope(journal):
+ run_durably_sync(lead, "go", run_config=NO_TRACING)
+ return journal
+
+ # The transfer's own result is the first function output the
specialist sees.
+ with pytest.raises(RuntimeError, match="worker lost"):
+ attempt(_ScriptedModel(lookups=2, fail_on_answer=True))
+ assert tool.calls == 1
+
+ specialist_model = _ScriptedModel(lookups=2)
+ journal = attempt(specialist_model)
+
+ assert journal.stats.replayed == {"model": 2, "tool": 1}
+ assert specialist_model.calls == 1
+ assert tool.calls == 1
+
+ @pytest.mark.parametrize(
+ ("by_name", "replayed_models"),
+ [
+ pytest.param(False, 1, id="model-instance"),
+ pytest.param(True, 2, id="model-name"),
+ ],
+ )
+ def test_a_handoff_object_agent_runs_its_own_tools_live(self, by_name,
replayed_models):
+ storage, tool = MemoryStorage(), _CountingTool()
+
+ def attempt(specialist_model: _ScriptedModel) -> DurableJournal:
+ specialist = Agent(
+ name="specialist",
+ model="specialist-model" if by_name else specialist_model,
+ tools=AirflowTools(tool.tool()),
+ )
+ lead = Agent(name="lead", model=_TransferModel(),
handoffs=[handoff(specialist)])
+ config = RunConfig(tracing_disabled=True,
model_provider=_Provider(specialist_model))
+ journal = DurableJournal(storage, clean_up_after_run=True)
+ with journal_scope(journal):
+ run_durably_sync(lead, "go", run_config=config)
+ return journal
+
+ with pytest.raises(RuntimeError, match="worker lost"):
+ attempt(_ScriptedModel(lookups=2, fail_on_answer=True))
+ assert tool.calls == 1
+
+ journal = attempt(_ScriptedModel(lookups=2))
+
+ assert journal.stats.replayed == {"model": replayed_models}
+ assert tool.calls == 2
+
+ @pytest.mark.asyncio
+ async def test_a_streamed_model_call_is_not_recorded(self):
+ events = [object(), object()]
+
+ async def stream(*args: Any, **kwargs: Any):
+ for event in events:
+ yield event
+
+ wrapped = mock.create_autospec(Model, instance=True)
+ wrapped.stream_response.side_effect = stream
+ storage = MemoryStorage()
+ durable_run = DurableJournal(storage).start_run()
+ model = _DurableModel(wrapped, durable_run)
+
+ streamed = [
+ event
+ async for event in model.stream_response(
+ None,
+ "go",
+ ModelSettings(),
+ [],
+ None,
+ [],
+ ModelTracing.DISABLED,
+ previous_response_id=None,
+ conversation_id=None,
+ prompt=None,
+ )
+ ]
+
+ assert streamed == events
+ wrapped.get_response.assert_not_called()
+ assert durable_run.position == 0
+ assert storage.steps() == []
+
+ def test_a_tool_that_needs_approval_is_not_invoked_or_recorded(self):
+ calls = []
+
+ @function_tool(needs_approval=True)
+ def lookup(key: str) -> str:
+ """Look a key up."""
+ calls.append(key)
+ return f"{key}=42"
+
+ journal = DurableJournal(MemoryStorage(), clean_up_after_run=True)
+ with journal_scope(journal):
+ result = run_durably_sync(
+ Agent(name="analyst", model=_ScriptedModel(), tools=[lookup]),
"go", run_config=NO_TRACING
+ )
+
+ assert len(result.interruptions) == 1
+ assert calls == []
+ assert journal.stats.recorded == {"model": 1}
+ assert _get_durable_tool(lookup, journal.start_run()) is not lookup