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 a7066062a13 Fix pulling `AgentOperator` output for list, marker and 
function types (#74729)
a7066062a13 is described below

commit a7066062a1394829ff04e0b35c84c2c25eb81518
Author: PoAn Yang <[email protected]>
AuthorDate: Sat Oct 24 01:18:54 2026 +0800

    Fix pulling `AgentOperator` output for list, marker and function types 
(#74729)
---
 providers/common/ai/docs/changelog.rst             |  23 ++
 providers/common/ai/docs/structured_output.rst     |  48 +++-
 .../airflow/providers/common/ai/operators/agent.py |  54 +++-
 .../tests/unit/common/ai/operators/test_agent.py   | 293 ++++++++++++++++++++-
 4 files changed, 395 insertions(+), 23 deletions(-)

diff --git a/providers/common/ai/docs/changelog.rst 
b/providers/common/ai/docs/changelog.rst
index 9d22ae90a69..4093a86db8f 100644
--- a/providers/common/ai/docs/changelog.rst
+++ b/providers/common/ai/docs/changelog.rst
@@ -25,6 +25,29 @@
 Changelog
 ---------
 
+.. note::
+  The worker cannot register a model class for XCom deserialization when 
``output_type`` is a
+  list of output types such as ``[Summary, int]``, an output marker such as
+  ``ToolOutput(Summary)``, an output function, a ``TypeAliasType`` or a 
``NewType``. For these
+  output types, ``AgentOperator`` and ``@task.agent`` now push each model in 
the output as a
+  JSON-compatible dict (``model_dump(mode="json")``), including models inside 
a list, tuple or
+  dict. On Airflow < 3.3, the worker registers no model class at all, so a 
model inside a list,
+  tuple or dict is now pushed as a JSON-compatible dict for every 
``output_type``. A top-level
+  model is still dumped with plain ``model_dump()`` when 
``serialize_output=True`` is set or on
+  Airflow < 3.3.
+
+  The new behaviour changes the type that a downstream task receives after an 
upgrade. Versions
+  1.0.0 and 1.1.0 pushed these models as model instances. They dumped a 
top-level model with
+  plain ``model_dump()`` only in HITL review, with ``serialize_output=True`` 
or on
+  Airflow < 3.3. A downstream task loaded a model instance when the model's 
class was listed in
+  ``[core] allowed_deserialization_classes``, or on Airflow >= 3.3 when 
another task in the
+  same Dag declared the class as its ``output_type``. That downstream task now 
gets a dict and
+  must read ``result["field"]`` instead of ``result.field``. In HITL review on 
Airflow >= 3.3
+  without ``serialize_output=True``, values such as ``datetime``, ``date``, 
``timedelta``,
+  ``UUID`` and ``Decimal`` in a top-level model now arrive as strings, for 
example
+  ``"PT1M30S"`` for a ``timedelta``. Tuples and sets in that model now arrive 
as lists. See
+  :doc:`structured_output`.
+
 1.1.0
 .....
 
diff --git a/providers/common/ai/docs/structured_output.rst 
b/providers/common/ai/docs/structured_output.rst
index b5e325695ee..8be2063916e 100644
--- a/providers/common/ai/docs/structured_output.rst
+++ b/providers/common/ai/docs/structured_output.rst
@@ -26,7 +26,7 @@ so downstream tasks can type-hint the class directly
 (``def downstream(result: MyModel)``) and use attribute access 
(``result.field``).
 
 The declared ``output_type`` (and any ``BaseModel`` reachable from
-``Union``/``Optional``/``list`` shapes) is registered for XCom deserialization 
by
+``Union``/``Optional``/``list[...]`` types) is registered for XCom 
deserialization by
 the worker when it loads the Dag, before any task runs -- so no edit to
 ``[core] allowed_deserialization_classes`` is needed. The Pydantic class must 
be
 defined at **module scope** and bound to an attribute matching its 
``__name__``;
@@ -50,6 +50,52 @@ loaded Dag's tasks at startup and registers each declared 
class, so it also work
 for mapped producers (``.expand(...)``) and for workers that load Dags from a
 cache that bypasses operator construction.
 
+The worker finds no class to register when ``output_type`` is a list of output
+types such as ``[Summary, int]``, an output marker such as
+``ToolOutput(Summary)``, an output function, a ``TypeAliasType`` or a
+``NewType``. For these output types, ``AgentOperator`` and ``@task.agent`` push
+each model in the output as a JSON-compatible dict, including models inside a
+list, tuple or dict. A model is pushed as a dict even if its class is listed in
+``[core] allowed_deserialization_classes``, or if another task in the same Dag
+declares the class as its ``output_type``. A downstream task therefore reads
+``result["field"]``, not ``result.field``.
+
+In a JSON-compatible dict, values such as ``datetime``, ``date``, ``UUID`` and
+``Decimal`` arrive as strings, tuples and sets arrive as lists, and ``Enum``
+members arrive as their values.
+
+A model at the top level is still dumped with plain ``model_dump()`` when
+``serialize_output=True`` is set or on Airflow versions before 3.3. Either
+condition is enough. A plain ``Enum`` member (one that does not also subclass
+``str`` or ``int``), an ``HttpUrl`` or a ``Path`` in that model then stays a
+Python object. The default XCom backend cannot serialize these objects, so the
+XCom push fails with ``TypeError``. Before Airflow 3.3 the worker registers no
+model class. ``AgentOperator`` and ``@task.agent`` therefore push a model
+inside a list, tuple or dict as a JSON-compatible dict for every
+``output_type``, not only for the output types listed above.
+
+Upgrading from common.ai 1.0.0 or 1.1.0 can change the type that a downstream
+task receives:
+
+* On Airflow 3.3 and later, common.ai 1.0.0 and 1.1.0 pushed each model in the
+  output as a model instance when ``output_type`` was one of the output types
+  listed above. They dumped a top-level model with plain ``model_dump()`` only
+  when ``serialize_output=True`` was set or in HITL review. A downstream task
+  loaded the instance when the class was listed in
+  ``[core] allowed_deserialization_classes`` or when another task in the same
+  Dag declared the class as its ``output_type``. That downstream task now gets
+  a dict, and ``result.field`` raises ``AttributeError``.
+* In HITL review on Airflow 3.3 and later without ``serialize_output=True``,
+  common.ai 1.0.0 and 1.1.0 dumped a top-level model of these output types with
+  plain ``model_dump()`` after the reviewer approved the model. Values such as
+  ``datetime``, ``date``, ``timedelta``, ``UUID`` and ``Decimal`` in that model
+  now arrive as strings, for example ``"PT1M30S"`` for a ``timedelta``. Tuples
+  and sets in that model now arrive as lists.
+* Before Airflow 3.3, common.ai 1.0.0 and 1.1.0 pushed a model inside a list,
+  tuple or dict as a model instance, for every ``output_type``. A downstream
+  task that loaded the instance because the class was listed in
+  ``[core] allowed_deserialization_classes`` now gets a dict.
+
 The Airflow UI's XCom viewer renders Pydantic instances via the
 ``stringify`` path, which produces a representation like
 ``my_module.MyModel@version=1(field=value,...)`` without consulting the
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
index 58f4c8b730c..407dad59148 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
@@ -241,6 +241,26 @@ def _declares_agent_template_fields(toolset: Any) -> bool:
     )
 
 
+def _dump_unregistered_models(value: Any, registered: set[type]) -> Any:
+    """Return *value* with each model whose class is not in *registered* 
dumped to a JSON-compatible dict."""
+    if isinstance(value, BaseModel):
+        if type(value) in registered:
+            return value
+        # A python-mode model_dump() leaves values such as plain Enum members, 
HttpUrl and Path in the
+        # dict, and the default XCom serializer raises TypeError for those 
values.
+        return value.model_dump(mode="json")
+    # The default XCom serializer already pushes a subclass of list, tuple or 
dict, such as a NamedTuple
+    # or an OrderedDict, as the plain type. Building the plain type here does 
not change what a
+    # downstream task receives.
+    if isinstance(value, list):
+        return [_dump_unregistered_models(item, registered) for item in value]
+    if isinstance(value, tuple):
+        return tuple(_dump_unregistered_models(item, registered) for item in 
value)
+    if isinstance(value, dict):
+        return {key: _dump_unregistered_models(item, registered) for key, item 
in value.items()}
+    return value
+
+
 # CancellableAgentRunMixin must precede BaseOperator so its on_kill overrides 
BaseOperator's
 # no-op. The other mixins only add methods, so they can trail BaseOperator. 
See the MRO guard
 # test in tests/unit/common/ai/mixins/test_cancellable_run.py.
@@ -1105,22 +1125,11 @@ class AgentOperator(CancellableAgentRunMixin, 
BaseOperator, HITLReviewMixin, Usa
             # The reviewer can approve, reject, or ask for a new answer, but 
cannot edit the answer text.
             # So the approved answer is the last answer the agent returned, 
and there is no need to parse
             # it back from that text.
-            output = self._output_under_review
-            if isinstance(output, BaseModel) and (
-                self._serialize_model_output or type(output) not in 
iter_pydantic_models(self.output_type)
-            ):
-                # A downstream task can load a model from XCom only if the 
worker registered the model's
-                # class. The worker registers the classes that 
iter_pydantic_models finds in output_type.
-                # iter_pydantic_models finds no class in a list of output 
types, an output marker, an output
-                # function, a TypeAliasType or a NewType, so the operator 
pushes the model as a dict. When
-                # iter_pydantic_models could not be imported, 
_serialize_model_output is always True, so the
-                # condition above never reaches the iter_pydantic_models call.
-                output = output.model_dump()
+            output = self._convert_output_for_xcom(self._output_under_review)
             self._clear_usage_budget(context)
             return output
 
-        if self._serialize_model_output and isinstance(output, BaseModel):
-            output = output.model_dump()
+        output = self._convert_output_for_xcom(output)
 
         # Clean up the durable journal only after the run and every post-run 
step
         # that can still fail (the run-metadata and message-history XCom pushes
@@ -1132,6 +1141,25 @@ class AgentOperator(CancellableAgentRunMixin, 
BaseOperator, HITLReviewMixin, Usa
         self._clear_usage_budget(context)
         return output
 
+    def _convert_output_for_xcom(self, output: Any) -> Any:
+        """Return the XCom value, with dicts in place of models that a 
downstream task could not load."""
+        if not self.do_xcom_push:
+            return output
+        if self._serialize_model_output and isinstance(output, BaseModel):
+            return output.model_dump()
+        if not _CORE_WALKER:
+            # iter_pydantic_models could not be imported, so the worker 
registers no class.
+            # _serialize_model_output is always True when that import fails, 
so a model at the top level was
+            # already dumped above. The class of a model inside a list, tuple 
or dict is not registered
+            # either, so this call passes an empty set of registered classes 
to dump that model too.
+            return _dump_unregistered_models(output, set())
+        # A downstream task can load a model from XCom only if the worker 
registered the model's class.
+        # The worker registers the classes that iter_pydantic_models finds in 
output_type.
+        # iter_pydantic_models finds no class in a list of output types, an 
output marker, an output
+        # function, a TypeAliasType or a NewType. For these output types the 
operator pushes each model
+        # as a dict, including models inside a list, tuple or dict.
+        return _dump_unregistered_models(output, 
set(iter_pydantic_models(self.output_type)))
+
     def _pause_for_tool_approval(self, context: Context, result: Any, *, 
attempt_usage: RunUsage) -> NoReturn:
         """
         Park the task until a human approves or rejects the tool calls the 
agent is waiting on.
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
index 178db398166..8ee85870a7b 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
@@ -21,16 +21,18 @@ import dataclasses
 import json
 import logging
 import sys
+from collections import OrderedDict
 from contextlib import nullcontext
 from datetime import timedelta
 from decimal import Decimal
+from enum import Enum
 from types import ModuleType, SimpleNamespace
-from typing import Any, NewType
+from typing import Any, NamedTuple, NewType
 from unittest.mock import ANY, MagicMock, PropertyMock, call, patch
 from uuid import UUID
 
 import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict, HttpUrl
 from pydantic_ai import Agent, DeferredToolRequests, RunContext, Tool, 
ToolOutput
 from pydantic_ai.capabilities import (
     AbstractCapability,
@@ -106,7 +108,11 @@ from unit.common.ai.durable.memory_storage import 
MemoryStorage
 from unit.common.ai.sandbox.fake_tags import TaggedBackend
 
 try:
-    from airflow.sdk.serde import SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as 
_CORE_WALKER
+    from airflow.sdk.serde import (
+        SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as _CORE_WALKER,
+        deserialize,
+        serialize,
+    )
 except ImportError:
     _CORE_WALKER = False
 
@@ -129,6 +135,42 @@ SummaryAlias = TypeAliasType("SummaryAlias", Summary)
 SummaryNewType = NewType("SummaryNewType", Summary)
 
 
+class SummaryKind(Enum):
+    SHORT = "short"
+
+
+class LinkedSummary(BaseModel):
+    kind: SummaryKind
+    url: HttpUrl
+
+
+class DetailedSummary(Summary):
+    detail: str = "more"
+
+
+class SummaryPair(NamedTuple):
+    summary: Summary
+    total: int
+
+
+class SummaryList(list):
+    pass
+
+
+class Unserializable:
+    pass
+
+
+class UnserializableSummary(BaseModel):
+    model_config = ConfigDict(arbitrary_types_allowed=True)
+
+    value: Unserializable
+
+
+class BytesSummary(BaseModel):
+    data: bytes
+
+
 def _make_mock_agent(output, make_mock_run_result, *, cost=None):
     """Create a mock agent that returns the given output.
 
@@ -1034,6 +1076,206 @@ class TestAgentOperatorExecute:
         assert result.text == "Great"
         assert result.score == 0.95
 
+    @pytest.mark.parametrize(
+        "output_type",
+        [
+            pytest.param([Summary, int], id="list-of-output-types"),
+            pytest.param(ToolOutput(Summary), id="output-marker"),
+            pytest.param(_summarize, id="output-function"),
+            pytest.param(SummaryAlias, id="type-alias"),
+            pytest.param(SummaryNewType, id="new-type"),
+        ],
+    )
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_dumps_a_model_the_worker_does_not_register(
+        self, mock_hook_cls, make_mock_run_result, output_type
+    ):
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
_make_mock_agent(
+            Summary(text="ok"), make_mock_run_result
+        )
+        op = AgentOperator(
+            task_id="test", prompt="Analyze this", llm_conn_id="my_llm", 
output_type=output_type
+        )
+
+        result = op.execute(context=MagicMock())
+
+        assert result == {"text": "ok", "score": 0.0}
+
+    @requires_typed_xcom
+    @pytest.mark.parametrize(
+        ("output_type", "output", "expected", "serialize_output"),
+        [
+            pytest.param(
+                ToolOutput(list[Summary]),
+                [Summary(text="ok")],
+                [{"text": "ok", "score": 0.0}],
+                False,
+                id="list-in-output-marker",
+            ),
+            pytest.param(
+                [list[Summary], int],
+                [Summary(text="ok")],
+                [{"text": "ok", "score": 0.0}],
+                False,
+                id="list-in-list-of-output-types",
+            ),
+            pytest.param(
+                ToolOutput(tuple[Summary, int]),
+                (Summary(text="ok"), 1),
+                ({"text": "ok", "score": 0.0}, 1),
+                False,
+                id="tuple-in-output-marker",
+            ),
+            pytest.param(
+                ToolOutput(dict[str, Summary]),
+                {"a": Summary(text="ok")},
+                {"a": {"text": "ok", "score": 0.0}},
+                False,
+                id="dict-in-output-marker",
+            ),
+            pytest.param(
+                ToolOutput(list[tuple[dict[str, list[Summary]], int]]),
+                [({"a": [Summary(text="ok")]}, 1)],
+                [({"a": [{"text": "ok", "score": 0.0}]}, 1)],
+                False,
+                id="nested-containers-in-output-marker",
+            ),
+            pytest.param(
+                ToolOutput(SummaryPair),
+                SummaryPair(Summary(text="ok"), 1),
+                ({"text": "ok", "score": 0.0}, 1),
+                False,
+                id="named-tuple-in-output-marker",
+            ),
+            pytest.param(
+                ToolOutput(list[Summary]),
+                SummaryList([Summary(text="ok")]),
+                [{"text": "ok", "score": 0.0}],
+                False,
+                id="list-subclass-in-output-marker",
+            ),
+            pytest.param(
+                ToolOutput(OrderedDict[str, Summary]),
+                OrderedDict(a=Summary(text="ok")),
+                {"a": {"text": "ok", "score": 0.0}},
+                False,
+                id="ordered-dict-in-output-marker",
+            ),
+            pytest.param(
+                list[Summary],
+                [DetailedSummary(text="ok")],
+                [{"text": "ok", "score": 0.0, "detail": "more"}],
+                False,
+                id="subclass-of-a-registered-model",
+            ),
+            pytest.param(
+                ToolOutput(list[Summary]),
+                [Summary(text="ok")],
+                [{"text": "ok", "score": 0.0}],
+                True,
+                id="list-in-output-marker-with-serialize-output",
+            ),
+            pytest.param(
+                ToolOutput(LinkedSummary),
+                LinkedSummary(kind=SummaryKind.SHORT, 
url=HttpUrl("https://example.com/a";)),
+                {"kind": "short", "url": "https://example.com/a"},
+                False,
+                id="enum-and-url-fields",
+            ),
+        ],
+    )
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_pushes_each_model_the_worker_does_not_register_as_json(
+        self, mock_hook_cls, make_mock_run_result, output_type, output, 
expected, serialize_output
+    ):
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
_make_mock_agent(
+            output, make_mock_run_result
+        )
+        op = AgentOperator(
+            task_id="test",
+            prompt="Analyze this",
+            llm_conn_id="my_llm",
+            output_type=output_type,
+            serialize_output=serialize_output,
+        )
+
+        result = op.execute(context=MagicMock())
+
+        assert result == expected
+        assert deserialize(serialize(result)) == expected
+
+    @requires_typed_xcom
+    @pytest.mark.parametrize(
+        ("output_type", "output", "key"),
+        [
+            pytest.param(list[Summary], [Summary(text="ok")], 0, id="list"),
+            pytest.param(tuple[Summary, int], (Summary(text="ok"), 1), 0, 
id="tuple"),
+            pytest.param(dict[str, Summary], {"a": Summary(text="ok")}, "a", 
id="dict"),
+        ],
+    )
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_keeps_each_model_the_worker_registers(
+        self, mock_hook_cls, make_mock_run_result, output_type, output, key
+    ):
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
_make_mock_agent(
+            output, make_mock_run_result
+        )
+        op = AgentOperator(
+            task_id="test", prompt="Analyze this", llm_conn_id="my_llm", 
output_type=output_type
+        )
+
+        result = op.execute(context=MagicMock())
+
+        assert result == output
+        assert type(result[key]) is Summary
+
+    @pytest.mark.parametrize(
+        "output_type",
+        [
+            pytest.param(ToolOutput(list[Summary]), 
id="list-in-output-marker"),
+            pytest.param(list[Summary], id="list"),
+        ],
+    )
+    @patch("airflow.providers.common.ai.operators.agent._CORE_WALKER", False)
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_dumps_nested_models_without_the_worker_walk(
+        self, mock_hook_cls, make_mock_run_result, output_type
+    ):
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
_make_mock_agent(
+            [Summary(text="ok")], make_mock_run_result
+        )
+        op = AgentOperator(
+            task_id="test", prompt="Analyze this", llm_conn_id="my_llm", 
output_type=output_type
+        )
+
+        result = op.execute(context=MagicMock())
+
+        assert result == [{"text": "ok", "score": 0.0}]
+
+    @pytest.mark.parametrize(
+        "output",
+        [
+            pytest.param(UnserializableSummary(value=Unserializable()), 
id="arbitrary-type-field"),
+            pytest.param(BytesSummary(data=b"\xff"), id="non-utf-8-bytes"),
+        ],
+    )
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_returns_the_output_unchanged_when_nothing_is_pushed(
+        self, mock_hook_cls, make_mock_run_result, output
+    ):
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
_make_mock_agent(
+            output, make_mock_run_result
+        )
+        op = AgentOperator(
+            task_id="test",
+            prompt="Analyze this",
+            llm_conn_id="my_llm",
+            output_type=[type(output), int],
+            do_xcom_push=False,
+        )
+
+        assert op.execute(context=MagicMock()) is output
+
     def test_declares_output_type_for_deserialization(self):
         """Declares ``output_type`` so the worker-side DAG walk registers it 
for deserialization."""
         assert "output_type" in 
AgentOperator.deserialization_allowed_class_fields
@@ -1103,14 +1345,14 @@ class TestAgentOperatorExecute:
     def test_execute_with_enable_hitl_review_delegates_to_run_hitl_review(
         self, mock_hook_cls, mock_run_hitl, make_mock_run_result
     ):
-        """When enable_hitl_review=True, execute delegates to run_hitl_review 
with output and message_history."""
+        """execute returns the agent's output instead of run_hitl_review's 
text."""
         msg_history = [MagicMock()]
         mock_result = make_mock_run_result("Initial output")
         mock_result.all_messages.return_value = msg_history
         mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
-        mock_run_hitl.return_value = "Initial output"
+        mock_run_hitl.return_value = "Approved output"
 
         op = AgentOperator(
             task_id="test",
@@ -1131,16 +1373,15 @@ class TestAgentOperatorExecute:
     )
     
@patch("airflow.providers.common.ai.operators.agent.AgentOperator.run_hitl_review",
 autospec=True)
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
-    def test_execute_with_hitl_rehydrates_base_model(
+    def test_execute_with_hitl_returns_the_model_instance(
         self, mock_hook_cls, mock_run_hitl, make_mock_run_result
     ):
-        """When enable_hitl_review=True and output_type is BaseModel, execute 
returns the model instance."""
+        """execute returns the agent's model instance, not a model parsed from 
run_hitl_review's text."""
         mock_result = make_mock_run_result(Summary(text="Approved summary", 
score=0.9))
         mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
-        # run_hitl_review returns JSON string (as stored in 
session.current_output)
-        mock_run_hitl.return_value = '{"text": "Approved summary", "score": 
0.9}'
+        mock_run_hitl.return_value = '{"text": "Edited"}'
 
         op = AgentOperator(
             task_id="test",
@@ -1204,6 +1445,19 @@ class TestAgentOperatorExecute:
             pytest.param(_summarize, Summary(text="ok"), {"text": "ok", 
"score": 0.0}, id="output-function"),
             pytest.param(SummaryAlias, Summary(text="ok"), {"text": "ok", 
"score": 0.0}, id="type-alias"),
             pytest.param(SummaryNewType, Summary(text="ok"), {"text": "ok", 
"score": 0.0}, id="new-type"),
+            pytest.param(
+                ToolOutput(list[Summary]),
+                [Summary(text="ok")],
+                [{"text": "ok", "score": 0.0}],
+                id="list-in-output-marker",
+            ),
+            pytest.param(
+                ToolOutput(LinkedSummary),
+                LinkedSummary(kind=SummaryKind.SHORT, 
url=HttpUrl("https://example.com/a";)),
+                {"kind": "short", "url": "https://example.com/a"},
+                id="enum-and-url-fields",
+                marks=requires_typed_xcom,
+            ),
         ],
     )
     
@patch("airflow.providers.common.ai.operators.agent.AgentOperator.run_hitl_review",
 autospec=True)
@@ -2040,6 +2294,27 @@ class TestAgentOperatorDurable:
 
         assert [step["name"] for step in storage.steps()] == 
["t__model.request"]
 
+    def test_cleanup_skipped_when_output_conversion_fails(self):
+        """The journal must survive a failed conversion of the output for 
XCom, so the Airflow retry
+        can still replay the recorded steps."""
+        storage = MemoryStorage()
+        op = AgentOperator(task_id="t", prompt="p", llm_conn_id="c", 
durable=True)
+        op.llm_hook = MagicMock(spec=["create_agent"])
+        op.llm_hook.create_agent.side_effect = lambda **kw: Agent(
+            FunctionModel(lambda messages, info: 
ModelResponse(parts=[TextPart("ok")])), **kw
+        )
+
+        with (
+            patch.object(AgentOperator, "_build_durable_storage", 
autospec=True, return_value=storage),
+            patch.object(
+                op, "_convert_output_for_xcom", autospec=True, 
side_effect=RuntimeError("cannot dump")
+            ),
+            pytest.raises(RuntimeError, match="cannot dump"),
+        ):
+            op.execute(context=_make_context())
+
+        assert [step["name"] for step in storage.steps()] == 
["t__model.request"]
+
     def test_supports_durable_execution_marker(self):
         assert AgentOperator._AgentOperator__supports_durable_execution is True
 

Reply via email to