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 3c254dbc58b Fix durable agent tasks failing when a tool returns bytes 
(#76861)
3c254dbc58b is described below

commit 3c254dbc58b629b5065748a9a2c13b0a694e6706
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Nov 11 15:19:07 2026 +0000

    Fix durable agent tasks failing when a tool returns bytes (#76861)
    
    A tool that returned bytes that are not UTF-8 text, such as a PNG, failed a 
durable agent
    task: the journal encoded tool results with pydantic-ai's JSON codec, which 
writes bytes as
    UTF-8 text. Bytes that are UTF-8 text were recorded as text and replayed as 
str, so the
    retry showed the model something other than the base64 it first saw.
    
    The journal now records bytes a tool returns, on their own or as values 
inside dicts and
    lists, as base64 and replays the same bytes. Secrets inside those bytes are 
masked before
    they are recorded, as they are for text. Entries recorded by earlier 
versions load as before.
    
    A result the journal still cannot encode fails the task as before. 
DurableJournal takes
    fail_on_unrecordable=False to log such a step and run it again on retry 
instead, for a
    journal whose durable execution was not requested explicitly.
    
    Only ``bytes`` is converted to base64; ``bytearray``, ``memoryview`` and
    sets of bytes are not, and the docs now say so.
    
    From pydantic-ai 2.66, pydantic-ai records a function toolset's bytes in
    a dataclass field itself, as base64, so such a result no longer fails to
    record there. The docs no longer claim a fixed outcome for bytes in a
    dataclass, and the test of a journal that leaves an unrecordable result
    behind uses an Airflow toolset, whose results go through the journal's
    own codec on every pydantic-ai version.
    
    Also rename ``JournalStep._not_recorded`` to ``_mark_not_recorded``.
    
    * Say what durable execution does with tool result bytes it does not convert
    
    ``bytearray``, ``memoryview``, sets of bytes and bytes in a dataclass or
    Pydantic model field either cannot be encoded, which fails the task, or
    come back changed, depending on the pydantic-ai version and the toolset.
    The docs now say so in one place.
    
    The test of a journal that leaves an unrecordable result behind covers
    function toolsets again, with the journal's codec refusing the result,
    since pydantic-ai 2.66 records that result itself.
    
    * Scope the durable execution docs promise of a failure without retry
    
    Only a result the journal itself cannot encode fails the task with
    ``DurableJournalError`` and no retry. Bytes in a Pydantic model field
    returned by an Airflow toolset fail earlier, in secret masking, with an
    error that is retried, so the docs no longer promise the no-retry
    failure for every unconverted bytes type.
    
    Name the ``set`` and ``frozenset`` types as code, so the docs spelling
    check passes.
---
 providers/common/ai/docs/durable_execution.rst     |  16 +-
 providers/common/ai/docs/toolsets/custom.rst       |   5 +-
 providers/common/ai/docs/troubleshooting.rst       |   8 +-
 .../providers/common/ai/durable/capability.py      | 137 ++++++--
 .../airflow/providers/common/ai/durable/journal.py |  54 +++-
 .../unit/common/ai/durable/test_capability.py      | 351 ++++++++++++++++++++-
 6 files changed, 533 insertions(+), 38 deletions(-)

diff --git a/providers/common/ai/docs/durable_execution.rst 
b/providers/common/ai/docs/durable_execution.rst
index 8b4cded05c5..1beaf3ef032 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -366,11 +366,17 @@ A toolset can opt out of replay. ``ManagedAgentToolset`` 
does by default
 (``replayable=False``), because a managed agent acts on a system Airflow 
cannot observe:
 its calls run again on every attempt, while the steps around them still replay.
 
-A result has to survive a JSON round trip to be recorded. One that cannot be 
encoded
-fails the task with ``DurableJournalError`` and no retry, since it would fail 
the same
-way every time; the model could not have read it either. A write the task 
state store
-refuses does not fail the task: the task log names
-the step, and a retry runs it again.
+A result has to survive a JSON round trip to be recorded. Bytes a tool 
returns, on their
+own or as values anywhere inside dicts and lists, are recorded as base64, and 
a retry
+replays the same bytes. Only ``bytes`` is converted. ``bytearray``, 
``memoryview``, a
+``set`` or ``frozenset`` of bytes, and bytes in a field of a dataclass or 
Pydantic model
+are not:
+depending on the pydantic-ai version and the toolset, such a result fails the 
task, or
+it comes back changed (as text, a list or a dict), which can make the steps 
after that
+tool call run again. Put bytes in a dict instead. A result the journal cannot 
encode,
+such as an object with no JSON form, fails the task with 
``DurableJournalError`` and no
+retry, since it would fail the same way every time. A write the task state 
store
+refuses does not fail the task: the task log names the step, and a retry runs 
it again.
 
 Limitations
 -----------
diff --git a/providers/common/ai/docs/toolsets/custom.rst 
b/providers/common/ai/docs/toolsets/custom.rst
index 37090e292d4..ce92eb12feb 100644
--- a/providers/common/ai/docs/toolsets/custom.rst
+++ b/providers/common/ai/docs/toolsets/custom.rst
@@ -102,8 +102,9 @@ What a subclass implements
     class's ``call_tool`` masks it on the way out. Implement ``execute_tool`` 
rather than
     overriding ``call_tool``, since an override skips that masking and 
``AgentOperator``
     then wraps the toolset in a masking layer of its own. With 
``durable=True`` a result
-    has to be JSON-serializable, or the task fails with 
``DurableJournalError`` and is
-    not retried.
+    has to be JSON-serializable or bytes (on their own or as values inside 
dicts and
+    lists), or the task fails with ``DurableJournalError`` and is not retried. 
Put bytes
+    in a dict, not in a field of a dataclass or Pydantic model.
 
 Two class attributes are optional:
 
diff --git a/providers/common/ai/docs/troubleshooting.rst 
b/providers/common/ai/docs/troubleshooting.rst
index 545318a9392..e5867ec9532 100644
--- a/providers/common/ai/docs/troubleshooting.rst
+++ b/providers/common/ai/docs/troubleshooting.rst
@@ -160,9 +160,11 @@ Run-time errors
     connection as described above.
 
 ``DurableJournalError: durable execution could not record a step's result, 
because it is not JSON-serializable``
-    A tool returned a value that cannot be encoded as JSON. The model could 
not have read
-    it either, so the task fails without retrying. Return text, numbers, 
lists, dicts or a
-    Pydantic model. See :doc:`durable_execution`.
+    A tool returned a value that cannot be encoded as JSON, such as an object 
with no JSON
+    form or a dataclass holding bytes that are not UTF-8 text. It would fail 
the same way
+    on every attempt, so the task fails without retrying. Return text, 
numbers, bytes,
+    lists, dicts or a Pydantic model without bytes fields; put bytes in a dict 
rather than
+    a field of your own class. See :doc:`durable_execution`.
 
 Durable execution on Airflow below 3.3 fails with a ``ValueError`` about 
``durable_cache_path``
     On Airflow versions older than 3.3 the step cache lives in object storage 
and
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py 
b/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
index 84b6ee31274..12d782d9010 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/capability.py
@@ -18,6 +18,8 @@
 
 from __future__ import annotations
 
+import base64
+import dataclasses
 from collections.abc import Awaitable, Callable, Iterator, Mapping
 from contextlib import contextmanager
 from contextvars import ContextVar
@@ -29,6 +31,7 @@ from pydantic_ai.durable_exec import (
     JSON_CODEC,
     BaseDurabilityCapability,
     CapabilityOperationId,
+    DurabilityCodec,
     DurabilityEngineSpec,
     DurableOperationId,
     JournalCallableOperationBackend,
@@ -51,7 +54,7 @@ from pydantic_ai.toolsets.wrapper import WrapperToolset
 from pydantic_core import PydanticSerializationError
 
 from airflow.providers.common.ai.durable.fingerprint import 
fingerprint_model_request, fingerprint_tool_call
-from airflow.providers.common.ai.durable.journal import current_journal
+from airflow.providers.common.ai.durable.journal import Unrecordable, 
current_journal
 from airflow.providers.common.ai.durable.replay_usage import 
ReplayUsageLedger, is_successful_tool_payload
 from airflow.providers.common.ai.exceptions import DurableJournalError
 from airflow.providers.common.ai.utils.masking import mask_secrets
@@ -121,13 +124,102 @@ def _active_run(active: _ActiveRun) -> Iterator[None]:
         _ACTIVE_RUN.reset(token)
 
 
-def _not_journalable(error: Exception) -> BaseException:
-    # The same value fails the same way on every retry, so retrying the task 
cannot help.
-    return DurableJournalError(
-        f"durable execution could not record a step's result, because it is 
not JSON-serializable: {error}"
+# A recorded ``bytes`` value: a dict whose only key is this, holding the bytes 
as base64.
+_BYTES_KEY = "__airflow_bytes__"
+
+# The kinds of pydantic-ai's recorded tool call result that hold the tool's 
value in ``result``.
+_TOOL_RESULT_KINDS = frozenset({"tool_return", "tool_content_result"})
+
+
+def _tag_bytes(value: Any) -> Any:
+    """
+    Replace the ``bytes`` in a tool result with base64 the journal can hold.
+
+    The model sees bytes as base64 (pydantic-ai's ``tool_return_ta``), but 
``JSON_CODEC``
+    encodes them as UTF-8 text: it fails on bytes that are not UTF-8 and turns 
the rest into
+    ``str``, so the retry would show the model something else. Tagged, they 
load back as the
+    same bytes. Only the parts of a result that a tool fills freely are 
walked; a typed value
+    such as ``BinaryContent`` encodes its own bytes.
+    """
+    if isinstance(value, bytes):
+        return {_BYTES_KEY: base64.b64encode(value).decode("ascii")}
+    if isinstance(value, dict):
+        return {key: _tag_bytes(item) for key, item in value.items()}
+    if isinstance(value, list | tuple):
+        return [_tag_bytes(item) for item in value]
+    if isinstance(value, ToolReturn):
+        return dataclasses.replace(
+            value, return_value=_tag_bytes(value.return_value), 
metadata=_tag_bytes(value.metadata)
+        )
+    if (
+        dataclasses.is_dataclass(value)
+        and not isinstance(value, type)
+        and getattr(value, "kind", None) in _TOOL_RESULT_KINDS
+        and any(attribute.name == "result" and attribute.init for attribute in 
dataclasses.fields(value))
+    ):
+        # pydantic-ai's recorded tool call result, a shape its journals keep 
loading across versions.
+        return dataclasses.replace(value, result=_tag_bytes(getattr(value, 
"result")))
+    return value
+
+
+def _untag_bytes(payload: Any) -> Any:
+    """
+    Turn what :func:`_tag_bytes` recorded back into bytes.
+
+    A payload without tags loads as it is, and so does a dict a tool built 
that only looks
+    like one, with a value that is not base64.
+    """
+    if isinstance(payload, dict):
+        if len(payload) == 1 and isinstance(encoded := 
payload.get(_BYTES_KEY), str):
+            try:
+                return base64.b64decode(encoded, validate=True)
+            except ValueError:  # binascii.Error, or a value that is not ASCII
+                return payload
+        return {key: _untag_bytes(item) for key, item in payload.items()}
+    if isinstance(payload, list):
+        return [_untag_bytes(item) for item in payload]
+    return payload
+
+
+def _mask_payload(payload: Any) -> Any:
+    """Mask the secrets in an encoded payload, including inside its bytes, 
which base64 hides from the masker."""
+    return _tag_bytes(mask_secrets(_untag_bytes(payload)))
+
+
+def _is_model_response(payload: Any) -> bool:
+    # Bytes are never tagged in a model response, so text the model wrote, 
such as tool call
+    # arguments, is never read as a tag. The second shape is a streamed 
response from
+    # pydantic-ai before 2.60, recorded with its events.
+    return isinstance(payload, dict) and (
+        payload.get("kind") == "response" or ("response" in payload and 
"events" in payload)
     )
 
 
+class _JournalCodec(DurabilityCodec):
+    """
+    pydantic-ai's ``JSON_CODEC``, recording bytes as base64 and returning 
failures instead of raising them.
+
+    A result that does not encode comes back as :class:`Unrecordable`, so the 
journal decides
+    whether that fails the task (:meth:`JournalStep.cannot_record`), and 
``load`` hands the
+    live value on to the run either way.
+    """
+
+    def dump(self, tp: Any, value: Any) -> Any:
+        try:
+            return JSON_CODEC.dump(tp, _tag_bytes(value))
+        except (PydanticSerializationError, TypeError, ValueError, 
RecursionError) as error:
+            # RecursionError: a result that contains itself, or is nested too 
deep to walk.
+            return Unrecordable(value, error)
+
+    def load(self, tp: Any, payload: Any) -> Any:
+        if isinstance(payload, Unrecordable):
+            return payload.value
+        return JSON_CODEC.load(tp, payload if _is_model_response(payload) else 
_untag_bytes(payload))
+
+
+_JOURNAL_CODEC = _JournalCodec()
+
+
 class AirflowDurability(BaseDurabilityCapability[AgentDepsT]):
     """
     Replay an agent's completed steps when Airflow retries the task it runs in.
@@ -176,8 +268,7 @@ class 
AirflowDurability(BaseDurabilityCapability[AgentDepsT]):
         engine_name="Airflow",
         durable_unit_noun="step",
         durable_container_noun="task",
-        codec=JSON_CODEC,
-        serialization_failure=_not_journalable,
+        codec=_JOURNAL_CODEC,
     )
 
     def __init__(
@@ -325,7 +416,7 @@ class 
_AirflowOperationBackend(JournalCallableOperationBackend[None]):
                     return step.payload
                 # The masking wrapper AgentOperator adds sits outside this 
durable unit, so mask
                 # before recording: secrets must not reach the journal any 
more than the model.
-                return await step.run(body, to_record=mask_secrets)
+                return await step.run(body, to_record=_mask_payload)
             case ToolsetValidateToolArgumentsId():
                 # Local validation of the arguments the recorded call was made 
with: nothing to
                 # save by replaying it, and it is the same on every attempt, 
so it takes no position.
@@ -371,7 +462,12 @@ class 
_AirflowOperationBackend(JournalCallableOperationBackend[None]):
             return step.payload
         ledger.record_live_model_request(had_request_credit=had_request_credit)
         payload = await step.run(body)
-        ledger.track_chain(_load_model_response(payload, streaming=streaming), 
parameters)
+        if isinstance(payload, Unrecordable):
+            # Left unrecorded by a journal that does not fail on it: the live 
response is the value.
+            response = payload.value if isinstance(payload.value, 
ModelResponse) else payload.value.response
+        else:
+            response = _load_model_response(payload, streaming=streaming)
+        ledger.track_chain(response, parameters)
         return payload
 
     async def _fingerprint_model_request(
@@ -403,8 +499,8 @@ class _JournaledToolset(WrapperToolset[Any]):
     Results, and the control-flow exceptions a tool raises for the model 
(``ModelRetry``,
     ``ToolFailed``, ``ApprovalRequired``, ``CallDeferred``), are recorded as 
values, so a
     retry replays them exactly. Any other exception is recorded as a failure 
and the call
-    runs again on retry. A result that is not JSON-serializable fails the 
task, as it does
-    for the toolsets pydantic-ai runs as durable steps.
+    runs again on retry. Results are encoded as for the toolsets pydantic-ai 
runs as durable
+    steps, bytes included, and one that cannot be encoded is handled the same 
way.
     """
 
     durability: AirflowDurability[Any] = field(repr=False, kw_only=True)
@@ -483,16 +579,15 @@ async def _run_and_record_tool(step: JournalStep, call: 
Callable[[], Awaitable[A
 
 
 def _record_tool_payload(step: JournalStep, payload: dict[str, Any]) -> None:
-    try:
-        encoded = JSON_CODEC.dump(Any, payload)
-    except (PydanticSerializationError, TypeError, ValueError) as error:
-        # As for the toolsets pydantic-ai runs as durable steps: the same 
value fails the same
-        # way on every attempt, and the model could not have read it either.
-        raise _not_journalable(error) from error
-    step.record(mask_secrets(encoded))
+    encoded = _JOURNAL_CODEC.dump(Any, payload)
+    if isinstance(encoded, Unrecordable):
+        step.cannot_record(encoded.error)
+    else:
+        step.record(_mask_payload(encoded))
 
 
 def _decode_tool_payload(payload: Any) -> Any:
+    payload = _untag_bytes(payload)
     kind = payload.get("kind") if isinstance(payload, dict) else None
     match kind:
         case "tool_return" if "tool_return" in payload:
@@ -525,14 +620,12 @@ def _is_journaled_tool_payload(payload: object) -> bool:
     if not isinstance(payload, dict):
         return False
     match payload.get("kind"):
-        case "tool_return" if "tool_return" in payload:
+        case "tool_return" if "tool_return" in payload or "result" in payload:
             try:
-                JSON_CODEC.load(ToolReturn, payload["tool_return"])
+                _decode_tool_payload(payload)
             except (TypeError, ValueError):
                 return False
             return True
-        case "tool_return":
-            return "result" in payload
         case "model_retry" | "tool_failed":
             return isinstance(payload.get("message"), str)
         case "approval_required" | "call_deferred":
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 2d437834e4d..c4fad4b9422 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
@@ -59,6 +59,7 @@ from typing import TYPE_CHECKING, Any, Literal
 
 from airflow.providers.common.ai.durable.base import RUN_ID_KEY, RUNS_KEY, 
build_run_meta_key, build_step_key
 from airflow.providers.common.ai.durable.storage import DurableStorage
+from airflow.providers.common.ai.exceptions import DurableJournalError
 from airflow.providers.common.ai.observability import 
make_task_instance_run_key
 from airflow.providers.common.ai.utils.task_logger import get_task_logger
 from airflow.providers.common.compat.sdk import AirflowException, 
get_current_context
@@ -77,6 +78,19 @@ StepKind = Literal["model", "tool", "other"]
 """What a step is, for the end-of-run summary and for adapters that treat 
model steps specially."""
 
 
+@dataclass(frozen=True)
+class Unrecordable:
+    """
+    A step result an adapter could not encode as JSON: the live value, and why.
+
+    A step body returns it in place of the payload; :meth:`JournalStep.run` 
passes it to
+    :meth:`JournalStep.cannot_record` instead of recording it, and returns it 
to the adapter.
+    """
+
+    value: Any
+    error: Exception
+
+
 @dataclass
 class JournalStep:
     """
@@ -127,7 +141,10 @@ class JournalStep:
         except Exception as error:
             self.fail(error)
             raise
-        self.record(payload if to_record is None else to_record(payload))
+        if isinstance(payload, Unrecordable):
+            self.cannot_record(payload.error)
+        else:
+            self.record(payload if to_record is None else to_record(payload))
         return payload
 
     @contextmanager
@@ -160,6 +177,28 @@ class JournalStep:
             self.durable_run.journal.stats.recorded[self.kind] += 1
             log.debug("Durable: recorded step", position=self.position, 
step=self.name)
             return True
+        self._mark_not_recorded()
+        return False
+
+    def cannot_record(self, error: Exception) -> None:
+        """
+        Note that the step's result has no form the journal can hold, such as 
a value with no JSON form.
+
+        :raises DurableJournalError: When the journal fails on such results, 
as it does by default.
+            Otherwise the step is left unrecorded, as when storage skips a 
write, and runs again on retry.
+        """
+        if not self.replayable:
+            # Never recorded anyway, so there is nothing to fail or report.
+            return
+        if self.durable_run.journal.fail_on_unrecordable:
+            # The same value fails the same way on every retry, so retrying 
the task cannot help.
+            raise DurableJournalError(
+                f"durable execution could not record a step's result, because 
it is not JSON-serializable: {error}"
+            ) from error
+        log.warning("Durable: step result is not JSON-serializable", 
step=self.name, error=str(error))
+        self._mark_not_recorded()
+
+    def _mark_not_recorded(self) -> None:
         self.durable_run.journal.stats.not_recorded.append((self.kind, 
self.name))
         # Named here, not only in the summary: this line is logged on every 
path,
         # including the failed attempt that Airflow retries.
@@ -168,7 +207,6 @@ class JournalStep:
             position=self.position,
             step=self.name,
         )
-        return False
 
     def fail(self, error: BaseException) -> None:
         """
@@ -482,11 +520,21 @@ class DurableJournal:
     :param clean_up_after_run: Delete a run's steps as soon as it succeeds. A 
journal the
         operator manages leaves this off and calls :meth:`cleanup` itself 
after the
         task's own post-run work has succeeded.
+    :param fail_on_unrecordable: Fail the task on a step result the journal 
cannot hold
+        (see :meth:`JournalStep.cannot_record`). With ``False`` the step is 
logged and runs
+        again on retry instead.
     """
 
-    def __init__(self, storage: DurableStorageProtocol, *, clean_up_after_run: 
bool = False) -> None:
+    def __init__(
+        self,
+        storage: DurableStorageProtocol,
+        *,
+        clean_up_after_run: bool = False,
+        fail_on_unrecordable: bool = True,
+    ) -> None:
         self.storage = storage
         self.clean_up_after_run = clean_up_after_run
+        self.fail_on_unrecordable = fail_on_unrecordable
         self.stats = JournalStats()
         self._runs: list[DurableRun] = []
         self._top_level_runs = 0
diff --git 
a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py 
b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
index 9aba084a323..7393f0f9a77 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
@@ -24,6 +24,7 @@ the retry runs the agent again from the top with a fresh 
journal over the same s
 from __future__ import annotations
 
 import asyncio
+import base64
 import dataclasses
 from collections.abc import Iterable
 from typing import TYPE_CHECKING, Any
@@ -38,6 +39,7 @@ from pydantic_ai.messages import (
     ModelResponse,
     TextPart,
     ToolCallPart,
+    ToolReturn,
     ToolReturnPart,
 )
 from pydantic_ai.models.function import AgentInfo, FunctionModel
@@ -45,7 +47,8 @@ from pydantic_ai.toolsets import FunctionToolset
 from pydantic_ai.usage import RequestUsage, RunUsage, UsageLimits
 
 from airflow.providers.common.ai.durable import AirflowDurability
-from airflow.providers.common.ai.durable.journal import DurableJournal, 
journal_scope
+from airflow.providers.common.ai.durable.capability import _JournalCodec
+from airflow.providers.common.ai.durable.journal import DurableJournal, 
Unrecordable, journal_scope
 from airflow.providers.common.ai.exceptions import DurableJournalError
 from airflow.providers.common.ai.utils.toolset_base import AirflowToolset, 
ensure_masked
 
@@ -54,6 +57,8 @@ from unit.common.ai.tool_parts import is_tool_result, 
is_tool_retry
 if TYPE_CHECKING:
     from pydantic_ai.toolsets.abstract import ToolsetTool
 
+PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
+
 
 class Calls:
     """Counts the live calls one test's model and tools make, across 
attempts."""
@@ -333,6 +338,303 @@ class TestResultsThatCannotBeRecorded:
         with pytest.raises(DurableJournalError, match="not JSON-serializable"):
             await attempt(memory_storage, agent)
 
+    @pytest.mark.parametrize(
+        ("toolset", "codec_refuses"),
+        [
+            # From pydantic-ai 2.66 on, pydantic-ai records a function 
toolset's bytes in a
+            # dataclass itself, as base64, so the codec refuses them to take 
the same path on
+            # every version. An Airflow toolset's result goes through this 
codec as it is.
+            pytest.param(lambda calls: _function_toolset(calls, _Image(PNG)), 
True, id="function-toolset"),
+            pytest.param(lambda calls: _WarehouseToolset(calls, _Image(PNG)), 
False, id="airflow-toolset"),
+        ],
+    )
+    @pytest.mark.asyncio
+    async def test_a_journal_that_does_not_fail_leaves_the_result_unrecorded(
+        self, memory_storage, monkeypatch, toolset, codec_refuses
+    ):
+        if codec_refuses:
+            dump = _JournalCodec.dump
+
+            def refusing_dump(codec: _JournalCodec, tp: Any, value: Any) -> 
Any:
+                if isinstance(getattr(value, "result", None), _Image):
+                    return Unrecordable(value, TypeError("no JSON form"))
+                return dump(codec, tp, value)
+
+            monkeypatch.setattr(_JournalCodec, "dump", refusing_dump)
+        calls = Calls()
+        fail_final = [True]
+
+        def build() -> Agent[None, str]:
+            return Agent(
+                FunctionModel(tool_then_answer(calls, "query", 
fail_final=fail_final)),
+                name="analyst",
+                toolsets=[toolset(calls)],
+                capabilities=[AirflowDurability()],
+            )
+
+        journals = [DurableJournal(memory_storage, fail_on_unrecordable=False) 
for _ in range(2)]
+        with journal_scope(journals[0]), pytest.raises(RuntimeError, 
match="worker died"):
+            await build().run("go")
+        fail_final[0] = False
+        with journal_scope(journals[1]):
+            result = await build().run("go")
+
+        # The model reads the bytes inside the dataclass, so the run goes on; 
the journal cannot.
+        assert result.output == f"answer: {_Image(PNG)}"
+        assert calls["query"] == 2
+        assert [kind for kind, _ in journals[0].stats.not_recorded] == ["tool"]
+
+    @pytest.mark.asyncio
+    async def test_a_result_that_contains_itself_fails_without_retrying(self, 
memory_storage):
+        looped: dict[str, Any] = {}
+        looped["self"] = looped
+        agent = Agent(
+            FunctionModel(tool_then_answer(Calls(), "query", 
fail_final=[False])),
+            name="analyst",
+            toolsets=[_function_toolset(Calls(), looped)],
+            capabilities=[AirflowDurability()],
+        )
+
+        with pytest.raises(DurableJournalError, match="not JSON-serializable"):
+            await attempt(memory_storage, agent)
+
+    @pytest.mark.parametrize("fail_on_unrecordable", [True, False])
+    @pytest.mark.asyncio
+    async def test_a_model_response_that_is_not_json(self, memory_storage, 
fail_on_unrecordable):
+        def model_fn(messages: list[ModelMessage], info: AgentInfo) -> 
ModelResponse:
+            return ModelResponse(parts=[TextPart("done")], 
provider_details={"handle": object()})
+
+        agent = Agent(FunctionModel(model_fn), name="analyst", 
capabilities=[AirflowDurability()])
+        journal = DurableJournal(memory_storage, 
fail_on_unrecordable=fail_on_unrecordable)
+
+        with journal_scope(journal):
+            if fail_on_unrecordable:
+                with pytest.raises(DurableJournalError, match="not 
JSON-serializable"):
+                    await agent.run("go")
+            else:
+                assert (await agent.run("go")).output == "done"
+
+        assert [kind for kind, _ in journal.stats.not_recorded] == ([] if 
fail_on_unrecordable else ["model"])
+
+    @pytest.mark.asyncio
+    async def 
test_a_result_of_a_toolset_that_is_not_replayable_is_not_checked(self, 
memory_storage):
+        calls = Calls()
+        agent = Agent(
+            FunctionModel(tool_then_answer(calls, "query", 
fail_final=[False])),
+            name="analyst",
+            toolsets=[_WarehouseToolset(calls, _Image(PNG), replayable=False)],
+            capabilities=[AirflowDurability()],
+        )
+        journal = DurableJournal(memory_storage)
+
+        with journal_scope(journal):
+            result = await agent.run("go")
+
+        assert result.output == f"answer: {_Image(PNG)}"
+        assert journal.stats.not_recorded == []
+
+
[email protected]
+class _Image:
+    """A tool result whose bytes the model can read but the journal cannot 
record."""
+
+    data: bytes
+
+
+def _function_toolset(calls: Calls, result: Any) -> FunctionToolset[None]:
+    """A function toolset with one ``query`` tool that returns ``result``."""
+    toolset: FunctionToolset[None] = FunctionToolset(id="db")
+
+    @toolset.tool_plain
+    def query(sql: str) -> Any:
+        calls.bump("query")
+        return result
+
+    return toolset
+
+
[email protected]
+class _Event:
+    """A tool result of the user's own whose ``kind`` matches pydantic-ai's 
recorded tool return."""
+
+    name: str
+    kind: str = "tool_return"
+
+    @property
+    def result(self) -> str:
+        """Not a field, so it is not the ``result`` field of pydantic-ai's 
recorded tool return."""
+        return self.name
+
+
+class TestBytesResults:
+    @pytest.mark.parametrize(
+        ("result", "content", "metadata"),
+        [
+            pytest.param(PNG, PNG, None, id="png"),
+            pytest.param(b"plain text", b"plain text", None, id="utf-8"),
+            pytest.param(
+                {"blob": b"\xff", "rows": [b"\xfe", 1]},
+                {"blob": b"\xff", "rows": [b"\xfe", 1]},
+                None,
+                id="nested",
+            ),
+            # A dict a tool built that only looks like a tag is kept as it is.
+            pytest.param(
+                {"__airflow_bytes__": "not base64!"},
+                {"__airflow_bytes__": "not base64!"},
+                None,
+                id="tag-shaped",
+            ),
+            pytest.param(
+                {"__airflow_bytes__": "café"}, {"__airflow_bytes__": "café"}, 
None, id="tag-shaped-non-ascii"
+            ),
+            # Recorded as a list, which is what the model sees either way.
+            pytest.param((PNG, 1), [PNG, 1], None, id="tuple"),
+            # pydantic-ai records a dict whose kind collides with its own tool 
return differently.
+            pytest.param(
+                {"kind": "tool-return", "blob": PNG}, {"kind": "tool-return", 
"blob": PNG}, None, id="kind"
+            ),
+            pytest.param(
+                ToolReturn(return_value={"png": PNG}, metadata={"thumbnail": 
PNG}),
+                {"png": PNG},
+                {"thumbnail": PNG},
+                id="tool-return",
+            ),
+        ],
+    )
+    @pytest.mark.asyncio
+    async def test_a_function_tool_result_with_bytes_replays_the_same_bytes(
+        self, memory_storage, result, content, metadata
+    ):
+        calls = Calls()
+        fail_final = [True]
+        seen: list[tuple[Any, Any]] = []
+
+        def model_fn(messages: list[ModelMessage], info: AgentInfo) -> 
ModelResponse:
+            calls.bump("model")
+            match responses_so_far(messages):
+                case 0:
+                    return ModelResponse(parts=[ToolCallPart("query", {"sql": 
"select 1"})])
+                case 1:
+                    return ModelResponse(parts=[ToolCallPart("notify", {})])
+            if fail_final[0]:
+                raise RuntimeError("worker died")
+            part = tool_returns(messages)[0]
+            seen.append((part.content, part.metadata))
+            return ModelResponse(parts=[TextPart("done")])
+
+        def build() -> Agent[None, str]:
+            toolset = _function_toolset(calls, result)
+
+            @toolset.tool_plain
+            def notify() -> str:
+                calls.bump("notify")
+                return "sent"
+
+            return Agent(
+                FunctionModel(model_fn),
+                name="analyst",
+                toolsets=[toolset],
+                capabilities=[AirflowDurability()],
+            )
+
+        with pytest.raises(RuntimeError, match="worker died"):
+            await attempt(memory_storage, build())
+        fail_final[0] = False
+        await attempt(memory_storage, build())
+
+        assert seen == [(content, metadata)]
+        assert calls["query"] == 1
+        # The model request after the replayed bytes matches the recorded one, 
so it replays too.
+        assert calls["notify"] == 1
+        assert calls["model"] == 4
+
+    @pytest.mark.parametrize(
+        "result",
+        [
+            pytest.param({"blob": PNG}, id="dict"),
+            pytest.param(ToolReturn(return_value={"blob": PNG}), 
id="tool-return"),
+        ],
+    )
+    @pytest.mark.asyncio
+    async def 
test_an_airflow_toolset_result_with_bytes_replays_the_same_bytes(self, 
memory_storage, result):
+        calls = Calls()
+        fail_final = [True]
+        expected = {"blob": PNG}
+
+        def build() -> Agent[None, str]:
+            return Agent(
+                FunctionModel(tool_then_answer(calls, "query", 
fail_final=fail_final)),
+                name="analyst",
+                toolsets=[_WarehouseToolset(calls, result)],
+                capabilities=[AirflowDurability()],
+            )
+
+        with pytest.raises(RuntimeError, match="worker died"):
+            await attempt(memory_storage, build())
+        fail_final[0] = False
+        run = await attempt(memory_storage, build())
+
+        assert run.output == f"answer: {expected}"
+        assert calls["query"] == 1
+
+    @pytest.mark.asyncio
+    async def test_a_result_with_its_own_kind_field_replays(self, 
memory_storage):
+        calls = Calls()
+        fail_final = [True]
+
+        def build() -> Agent[None, str]:
+            return Agent(
+                FunctionModel(tool_then_answer(calls, "query", 
fail_final=fail_final)),
+                name="analyst",
+                toolsets=[_function_toolset(calls, _Event("deploy"))],
+                capabilities=[AirflowDurability()],
+            )
+
+        with pytest.raises(RuntimeError, match="worker died"):
+            await attempt(memory_storage, build())
+        fail_final[0] = False
+        run = await attempt(memory_storage, build())
+
+        assert run.output == "answer: {'name': 'deploy', 'kind': 
'tool_return'}"
+        assert calls["query"] == 1
+
+    @pytest.mark.asyncio
+    async def 
test_a_replayed_model_response_keeps_arguments_that_look_like_bytes(self, 
memory_storage):
+        """Only tool results are tagged, so what the model wrote replays as it 
was written."""
+        fail_final = [True]
+        seen: list[Any] = []
+        args = {"payload": {"__airflow_bytes__": "aGk="}}
+
+        def model_fn(messages: list[ModelMessage], info: AgentInfo) -> 
ModelResponse:
+            if responses_so_far(messages) == 0:
+                return ModelResponse(parts=[ToolCallPart("store", args)])
+            if fail_final[0]:
+                raise RuntimeError("worker died")
+            seen.append(next(part.args for part in messages[1].parts if 
isinstance(part, ToolCallPart)))
+            return ModelResponse(parts=[TextPart("done")])
+
+        def build() -> Agent[None, str]:
+            toolset = FunctionToolset(id="db")
+
+            @toolset.tool_plain
+            def store(payload: dict[str, Any]) -> str:
+                return "stored"
+
+            return Agent(
+                FunctionModel(model_fn),
+                name="analyst",
+                toolsets=[toolset],
+                capabilities=[AirflowDurability()],
+            )
+
+        with pytest.raises(RuntimeError, match="worker died"):
+            await attempt(memory_storage, build())
+        fail_final[0] = False
+        await attempt(memory_storage, build())
+
+        assert seen == [args]
+
 
 class TestStreaming:
     @pytest.mark.asyncio
@@ -463,13 +765,20 @@ class TestUnloadableRecords:
 
         assert calls["accrue"] == 2
 
+    @pytest.mark.parametrize(
+        "payload",
+        [
+            pytest.param({"kind": "renamed_in_a_later_version"}, 
id="unknown-kind"),
+            pytest.param({"kind": "tool_return", "tool_return": "not a tool 
return"}, id="tool-return"),
+        ],
+    )
     @pytest.mark.asyncio
-    async def 
test_an_airflow_toolset_record_that_no_longer_loads_runs_live(self, 
memory_storage):
+    async def 
test_an_airflow_toolset_record_that_no_longer_loads_runs_live(self, 
memory_storage, payload):
         calls = Calls()
         await attempt(memory_storage, self.build(calls, 
_WarehouseToolset(calls)))
         for entry in memory_storage.steps():
             if entry["kind"] == "tool":
-                entry["payload"] = {"kind": "renamed_in_a_later_version"}
+                entry["payload"] = payload
 
         result = await attempt(memory_storage, self.build(calls, 
_WarehouseToolset(calls)))
 
@@ -741,6 +1050,42 @@ class TestMasking:
 
         assert registered_secret not in str(memory_storage.entries)
 
+    @pytest.mark.parametrize(
+        "toolset",
+        [
+            pytest.param(lambda calls, result: _function_toolset(calls, 
result), id="function-toolset"),
+            pytest.param(
+                lambda calls, result: _UnmaskedWarehouseToolset(calls, 
result), id="airflow-toolset"
+            ),
+        ],
+    )
+    @pytest.mark.asyncio
+    async def test_journal_holds_secrets_inside_bytes_masked(
+        self, memory_storage, registered_secret, toolset
+    ):
+        result = f"password={registered_secret}".encode()
+        agent = Agent(
+            FunctionModel(tool_then_answer(Calls(), "query", 
fail_final=[False])),
+            name="analyst",
+            # AgentOperator puts the masking wrapper outside the durable unit.
+            toolsets=[ensure_masked(toolset(Calls(), result))],
+            capabilities=[AirflowDurability()],
+        )
+
+        await attempt(memory_storage, agent)
+
+        journal = str(memory_storage.entries)
+        assert registered_secret not in journal
+        assert base64.b64encode(result).decode() not in journal
+        assert base64.b64encode(b"password=***").decode() in journal
+
+
+class _UnmaskedWarehouseToolset(_WarehouseToolset):
+    """An Airflow toolset whose ``call_tool`` override skips the masking the 
base class does."""
+
+    async def call_tool(self, name, tool_args, ctx, tool) -> Any:
+        return await self.execute_tool(name, tool_args, ctx=ctx, tool=tool)
+
 
 class _Ledger(AbstractCapability[Any]):
     """Stands in for pydantic-ai-harness SpendLimits: it accrues through a 
durable operation."""

Reply via email to