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

Lee-W 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 7811cbacf5a Reject unsupported output_type and bound HITL XCom pull 
retries (#74450)
7811cbacf5a is described below

commit 7811cbacf5ab5227450c80bf2bd982c3c41eb371
Author: Wei Lee <[email protected]>
AuthorDate: Fri Oct 9 15:18:19 2026 +0900

    Reject unsupported output_type and bound HITL XCom pull retries (#74450)
---
 providers/common/ai/docs/hitl_review.rst           |  9 ++++--
 .../providers/common/ai/mixins/hitl_review.py      | 21 ++++++++++--
 .../airflow/providers/common/ai/operators/agent.py |  6 ++--
 .../airflow/providers/common/ai/operators/llm.py   |  9 ++++--
 .../providers/common/ai/operators/llm_branch.py    |  6 +++-
 .../common/ai/operators/llm_schema_compare.py      |  4 +++
 .../providers/common/ai/operators/llm_sql.py       |  5 ++-
 .../ai/decorators/test_llm_schema_compare.py       | 14 ++++++++
 .../unit/common/ai/decorators/test_llm_sql.py      | 12 +++++++
 .../unit/common/ai/mixins/test_hitl_review.py      | 37 ++++++++++++++++++++--
 .../unit/common/ai/operators/test_llm_branch.py    | 19 +++++------
 .../common/ai/operators/test_llm_schema_compare.py |  6 ++++
 .../tests/unit/common/ai/operators/test_llm_sql.py |  4 +++
 13 files changed, 127 insertions(+), 25 deletions(-)

diff --git a/providers/common/ai/docs/hitl_review.rst 
b/providers/common/ai/docs/hitl_review.rst
index 1b40a731dc8..40df880ccdb 100644
--- a/providers/common/ai/docs/hitl_review.rst
+++ b/providers/common/ai/docs/hitl_review.rst
@@ -23,7 +23,8 @@ Human-in-the-loop (HITL) review for agents
 HITL Review adds an interactive feedback loop to agentic operators. After the
 LLM Agent produces an initial output, a human reviewer can **approve**, 
**reject**, or
 **request changes** through a chat UI. The operator blocks until a
-terminal action, or until a timeout is reached or max_iterations reached.
+terminal action, or until a timeout is reached, max_iterations is reached, or
+polling the review XCom keeps failing.
 
 This document describes the architecture, workflow, API, XCom schema, and 
usage.
 
@@ -50,7 +51,8 @@ the API server and accesses the metadata database.
 
 .. important::
    **Worker slot usage**: Each HITL task **holds a worker slot for the entire
-   review duration** (until approve, reject, or timeout or max_iterations). 
The operator polls
+   review duration** (until approve, reject, timeout, max_iterations, or
+   repeated XCom polling failures). The operator polls
    XCom with ``time.sleep``; it does not defer. With a 10-second poll interval
    and review times of 30+ minutes, the worker is occupied for the duration.
 
@@ -133,7 +135,8 @@ Enable the review loop with ``enable_hitl_review=True``:
   changes at iterations 1 to 4; the fifth output must be either approved or
   rejected. Default ``5``.
 - ``hitl_timeout``: Maximum wall-clock time to wait for all review rounds.
-  ``None`` = no timeout (blocks until a terminal action).
+  ``None`` = no wall-clock timeout. The task still fails, re-raising the XCom 
error, if polling
+  the human action XCom fails 10 times in a row.
 - ``hitl_poll_interval``: Seconds between XCom polls while waiting for a
   human response. Default ``10``.
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py 
b/providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py
index 612a902a223..8a1327a871e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py
@@ -35,6 +35,9 @@ from airflow.providers.common.ai.utils.hitl_review import (
 
 log = logging.getLogger(__name__)
 
+# ``hitl_timeout`` may be ``None``, so a persistently failing poll would 
otherwise loop forever.
+_MAX_CONSECUTIVE_XCOM_PULL_FAILURES = 10
+
 if TYPE_CHECKING:
     from airflow.sdk import Context
 
@@ -64,7 +67,9 @@ class HITLReviewMixin:
     4. When a human sets action to ``approved``, returns the output.
     5. When a human sets action to ``rejected``, raises a `HITLRejectException`
 
-    The loop stops after ``hitl_timeout`` or ``max_hitl_iterations``.
+    The loop stops after ``hitl_timeout`` or ``max_hitl_iterations``. It also 
stops, by
+    re-raising the underlying exception, when polling the human action XCom 
fails
+    ``_MAX_CONSECUTIVE_XCOM_PULL_FAILURES`` times in a row, even if 
``hitl_timeout`` is ``None``.
 
     **Max iterations:** ``iteration`` counts outputs shown to the reviewer
     (1 = initial, 2 = first regeneration, etc.). When the reviewer requests
@@ -107,6 +112,8 @@ class HITLReviewMixin:
         :raises HITLMaxIterationsError: When max iterations reached without 
approval.
         :raises HITLRejectException: When the reviewer rejects the output.
         :raises HITLTimeoutError: When hitl_timeout elapses with no response.
+        :raises Exception: The last XCom pull exception, when pulling the 
human action
+            fails ``_MAX_CONSECUTIVE_XCOM_PULL_FAILURES`` times in a row.
         """
         output_str = self._to_string(output)  # type: ignore[attr-defined]
         ti = context["task_instance"]
@@ -150,7 +157,8 @@ class HITLReviewMixin:
         """
         Block until the session reaches a terminal state.
 
-        This loops until the human approves, rejects, or the timeout/max 
iterations is reached.
+        This loops until the human approves, rejects, the timeout/max 
iterations is reached,
+        or pulling the human action XCom fails 
``_MAX_CONSECUTIVE_XCOM_PULL_FAILURES`` times in a row.
         """
         from airflow.providers.standard.exceptions import (
             HITLRejectException,
@@ -159,6 +167,7 @@ class HITLReviewMixin:
 
         last_seen_iteration = 0
         first_poll = True
+        consecutive_pull_failures = 0
 
         while True:
             if deadline is not None and time.monotonic() > deadline:
@@ -181,8 +190,16 @@ class HITLReviewMixin:
                     key=XCOM_HUMAN_ACTION, task_ids=ti.task_id, 
map_indexes=ti.map_index
                 )
             except Exception:
+                consecutive_pull_failures += 1
+                if consecutive_pull_failures >= 
_MAX_CONSECUTIVE_XCOM_PULL_FAILURES:
+                    self.log.error(
+                        "Giving up HITL review after %d consecutive XCom pull 
failures",
+                        consecutive_pull_failures,
+                    )
+                    raise
                 self.log.warning("Failed to pull XCom", exc_info=True)
                 continue
+            consecutive_pull_failures = 0
 
             if action_raw is None:
                 # Human action may take some time to propagate; it must be 
performed in the UI,
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 3c45bed198b..bc8ff30ac12 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
@@ -383,8 +383,10 @@ class AgentOperator(CancellableAgentRunMixin, 
BaseOperator, HITLReviewMixin):
         without calling the LLM. E.g. 5 allows changes at iterations 1–4.
         Default ``5``.
     :param hitl_timeout: Maximum wall-clock time to wait for
-        all review rounds combined.  ``None`` means no timeout (the
-        operator blocks until a terminal action).
+        all review rounds combined.  ``None`` means no wall-clock timeout;
+        the review still ends on a terminal action (approve or reject),
+        ``max_hitl_iterations``, or when polling the human action XCom fails
+        too many times in a row.
     :param hitl_poll_interval: Seconds between XCom polls
         while waiting for a human response.  Default ``10``.
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
index 85b8465295b..c8837773caa 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
@@ -60,6 +60,8 @@ except ImportError:  # pragma: no cover - missing 
``apache-airflow-task-sdk`` wa
     _CORE_WALKER = False
 
 if TYPE_CHECKING:
+    from types import UnionType
+
     from pydantic_ai import Agent
     from pydantic_ai.usage import UsageLimits
 
@@ -99,8 +101,9 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator, 
LLMApprovalMixin):
         :class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook`
         for how blank entries in the list are dropped.
     :param system_prompt: System-level instructions for the LLM agent.
-    :param output_type: Expected output type. Default ``str``. Set to a 
Pydantic
-        ``BaseModel`` subclass for structured output; the model instance is
+    :param output_type: Expected output type. Default ``str``. A single type
+        (``str``, ``int``, ``list[str]``, a Pydantic ``BaseModel`` subclass) 
or a
+        union (``A | B``). For a ``BaseModel`` subclass, the model instance is
         returned to XCom unchanged so downstream tasks can type-hint it
         directly. The class must be defined at module scope -- nested classes
         cannot be deserialized from XCom.
@@ -197,7 +200,7 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator, 
LLMApprovalMixin):
         model_id: str | None = None,
         fallback_conn_ids: list[str] | None = None,
         system_prompt: str = "",
-        output_type: type = str,
+        output_type: type | UnionType = str,
         agent_params: dict[str, Any] | None = None,
         usage_limits: UsageLimits | dict[str, Any] | None = None,
         require_approval: bool = False,
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
index 2942b1601d4..21314fc2342 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
@@ -160,7 +160,11 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
         ignore_downstream_trigger_rules: bool = False,
         **kwargs: Any,
     ) -> None:
-        kwargs.pop("output_type", None)
+        if "output_type" in kwargs:
+            raise TypeError(
+                "LLMBranchOperator does not accept 'output_type'; it builds 
the output type "
+                "itself from the downstream task IDs."
+            )
         super().__init__(**kwargs)
         self.branches = self._normalize_branches(branches)
         self.allow_multiple_branches = allow_multiple_branches
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
index cc2b07c853b..1865fcd6e5f 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
@@ -154,6 +154,10 @@ class LLMSchemaCompareOperator(LLMOperator):
         system_prompt: str = DEFAULT_SYSTEM_PROMPT,
         **kwargs: Any,
     ) -> None:
+        if "output_type" in kwargs:
+            raise TypeError(
+                "LLMSchemaCompareOperator does not accept 'output_type'; it 
always returns SchemaCompareResult."
+            )
         kwargs["output_type"] = SchemaCompareResult
         # execute() always returns a dict, so the approval resume path must too
         kwargs["serialize_output"] = True
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py
index 847e3d1ee26..879a8c440fc 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py
@@ -128,7 +128,10 @@ class LLMSQLQueryOperator(LLMOperator):
         datasource_config: DataSourceConfig | None = None,
         **kwargs: Any,
     ) -> None:
-        kwargs.pop("output_type", None)  # SQL operator always returns str
+        if "output_type" in kwargs:
+            raise TypeError(
+                "LLMSQLQueryOperator does not accept 'output_type'; it always 
returns the generated SQL as str."
+            )
         super().__init__(**kwargs)
         self.db_conn_id = db_conn_id
         self.table_names = table_names
diff --git 
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
 
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
index b56e4eed118..7cfd2bece85 100644
--- 
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
+++ 
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_schema_compare.py
@@ -26,6 +26,7 @@ from airflow.providers.common.ai.operators.llm_schema_compare 
import (
     LLMSchemaCompareOperator,
     SchemaCompareResult,
 )
+from airflow.providers.common.compat.sdk import DAG, task
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
 
@@ -48,6 +49,19 @@ class TestLLMSchemaCompareDecoratedOperator:
     def test_custom_operator_name(self):
         assert _LLMSchemaCompareDecoratedOperator.custom_operator_name == 
"@task.llm_schema_compare"
 
+    def test_output_type_rejected(self):
+        @task.llm_schema_compare(
+            llm_conn_id="my_llm", db_conn_ids=["conn_a", "conn_b"], 
table_names=["t"], output_type=int
+        )
+        def my_prompt_fn():
+            return "Compare schemas"
+
+        with (
+            DAG(dag_id="dag"),
+            pytest.raises(TypeError, match="LLMSchemaCompareOperator does not 
accept 'output_type'"),
+        ):
+            my_prompt_fn()
+
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     @patch.object(LLMSchemaCompareOperator, "_build_schema_context", 
return_value="mocked schema")
     def test_execute_calls_callable_and_uses_result_as_prompt(
diff --git 
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py 
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
index e2d1f2c54b8..5aa2f730ae2 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_sql.py
@@ -22,6 +22,7 @@ import pytest
 from pydantic_ai.messages import ImageUrl
 
 from airflow.providers.common.ai.decorators.llm_sql import 
_LLMSQLDecoratedOperator
+from airflow.providers.common.compat.sdk import DAG, task
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
 
@@ -30,6 +31,17 @@ class TestLLMSQLDecoratedOperator:
     def test_custom_operator_name(self):
         assert _LLMSQLDecoratedOperator.custom_operator_name == "@task.llm_sql"
 
+    def test_output_type_rejected(self):
+        @task.llm_sql(llm_conn_id="my_llm", output_type=int)
+        def my_prompt_fn():
+            return "Get all users"
+
+        with (
+            DAG(dag_id="dag"),
+            pytest.raises(TypeError, match="LLMSQLQueryOperator does not 
accept 'output_type'"),
+        ):
+            my_prompt_fn()
+
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_calls_callable_and_uses_result_as_prompt(self, 
mock_hook_cls, make_mock_run_result):
         """The user's callable return value becomes the LLM prompt."""
diff --git 
a/providers/common/ai/tests/unit/common/ai/mixins/test_hitl_review.py 
b/providers/common/ai/tests/unit/common/ai/mixins/test_hitl_review.py
index 1bf319f5745..fba67629ce1 100644
--- a/providers/common/ai/tests/unit/common/ai/mixins/test_hitl_review.py
+++ b/providers/common/ai/tests/unit/common/ai/mixins/test_hitl_review.py
@@ -27,7 +27,10 @@ from datetime import timedelta
 from unittest.mock import MagicMock, patch
 
 from airflow.providers.common.ai.exceptions import HITLMaxIterationsError
-from airflow.providers.common.ai.mixins.hitl_review import HITLReviewMixin
+from airflow.providers.common.ai.mixins.hitl_review import (
+    _MAX_CONSECUTIVE_XCOM_PULL_FAILURES,
+    HITLReviewMixin,
+)
 from airflow.providers.common.ai.utils.hitl_review import (
     XCOM_AGENT_OUTPUT_PREFIX,
     XCOM_AGENT_SESSION,
@@ -57,7 +60,7 @@ class FakeAgenticOperator(HITLReviewMixin):
         self.max_hitl_iterations = 5
         self.hitl_timeout = hitl_timeout or timedelta(seconds=30)
         self.hitl_poll_interval = hitl_poll_interval
-        self.log = MagicMock(spec=["info", "warning"])
+        self.log = MagicMock(spec=["info", "warning", "error"])
 
     def regenerate_with_feedback(self, *, feedback: str, message_history):
         return f"Revised: {feedback}", message_history
@@ -218,6 +221,36 @@ class TestHITLReviewMixin:
         with pytest.raises(HITLTimeoutError, match="Task exceeded timeout"):
             fake_op.run_hitl_review(context, "Output")
 
+    @patch("airflow.providers.common.ai.mixins.hitl_review.time.sleep", 
autospec=True)
+    def test_xcom_pull_failures_raise_after_limit(self, mock_sleep, fake_op, 
mock_ti, context):
+        fake_op.hitl_timeout = None
+
+        def failing_pull(*args, **kwargs):
+            if mock_ti.xcom_pull.call_count > 
_MAX_CONSECUTIVE_XCOM_PULL_FAILURES + 2:
+                pytest.fail("XCom pull retried past the limit")
+            raise ConnectionError("api server down")
+
+        mock_ti.xcom_pull.side_effect = failing_pull
+
+        with pytest.raises(ConnectionError, match="api server down"):
+            fake_op.run_hitl_review(context, "Output")
+
+        assert mock_ti.xcom_pull.call_count == 
_MAX_CONSECUTIVE_XCOM_PULL_FAILURES
+        assert fake_op.log.warning.call_count == 
_MAX_CONSECUTIVE_XCOM_PULL_FAILURES - 1
+        fake_op.log.error.assert_called_once_with(
+            "Giving up HITL review after %d consecutive XCom pull failures",
+            _MAX_CONSECUTIVE_XCOM_PULL_FAILURES,
+        )
+
+    @patch("airflow.providers.common.ai.mixins.hitl_review.time.sleep", 
autospec=True)
+    def test_xcom_pull_failure_counter_resets_on_success(self, mock_sleep, 
fake_op, mock_ti, context):
+        fake_op.hitl_timeout = None
+        approve = HumanActionData(action="approve", 
iteration=1).model_dump(mode="json")
+        failures = [ConnectionError("blip")] * 
(_MAX_CONSECUTIVE_XCOM_PULL_FAILURES - 1)
+        mock_ti.xcom_pull.side_effect = [*failures, None, *failures, approve]
+
+        assert fake_op.run_hitl_review(context, "Output") == "Output"
+
     @patch.object(FakeAgenticOperator, "regenerate_with_feedback")
     @patch("airflow.providers.common.ai.mixins.hitl_review.time.sleep", 
autospec=True)
     def test_empty_feedback_with_changes_requested_treats_as_approve(
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
index 5ecb7d26abe..d57b4d45f40 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
@@ -51,17 +51,14 @@ class TestLLMBranchOperator:
     def test_template_fields(self):
         assert set(LLMBranchOperator.template_fields) == 
{*LLMOperator.template_fields, "branches"}
 
-    def test_output_type_ignored(self):
-        """Passing output_type= doesn't break anything; it's silently 
dropped."""
-        op = LLMBranchOperator(
-            task_id="test",
-            prompt="pick a branch",
-            llm_conn_id="my_llm",
-            output_type=int,
-        )
-        # output_type is overridden to str (the LLMOperator default) since
-        # the real output_type is built dynamically from downstream_task_ids
-        assert op.output_type is str
+    def test_output_type_rejected(self):
+        with pytest.raises(TypeError, match="does not accept 'output_type'"):
+            LLMBranchOperator(
+                task_id="test",
+                prompt="pick a branch",
+                llm_conn_id="my_llm",
+                output_type=int,
+            )
 
     @patch.object(LLMBranchOperator, "do_branch")
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
index 8be1f8c5e7d..3b0f4034938 100644
--- 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
+++ 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
@@ -147,6 +147,12 @@ class TestLLMSchemaCompareOperator:
         op = LLMSchemaCompareOperator(**_BASE_KWARGS, **kwargs)
         assert op.context_strategy == "full"
 
+    def test_output_type_rejected(self):
+        with pytest.raises(TypeError, match="LLMSchemaCompareOperator does not 
accept 'output_type'"):
+            LLMSchemaCompareOperator(
+                **_BASE_KWARGS, db_conn_ids=["conn_a", "conn_b"], 
table_names=["t"], output_type=int
+            )
+
     def test_init_succeeds_with_all_parameters(self):
         ds = _make_ds_config(conn_id="ds")
         op = LLMSchemaCompareOperator(
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
index 208f439319c..74161d00f53 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
@@ -192,6 +192,10 @@ class TestLLMSQLQueryOperator:
 
         assert issubclass(LLMSQLQueryOperator, LLMOperator)
 
+    def test_output_type_rejected(self):
+        with pytest.raises(TypeError, match="LLMSQLQueryOperator does not 
accept 'output_type'"):
+            LLMSQLQueryOperator(task_id="test", prompt="get users", 
llm_conn_id="my_llm", output_type=int)
+
     def test_template_fields_include_parent_and_sql_specific(self):
         expected = {
             "prompt",

Reply via email to