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

guan404ming 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 44ea07505c6 Skip downstream tasks on LLMBranchOperator reject (#71073)
44ea07505c6 is described below

commit 44ea07505c6e312a6322dbdb2b72cdb79b4484c4
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Thu Aug 27 22:05:07 2026 +0800

    Skip downstream tasks on LLMBranchOperator reject (#71073)
    
    * Skip downstream tasks on LLMBranchOperator reject
    
    * Keep teardowns running and log reviewer on reject
    
    * Scope teardown carve-out docs to the reject path
---
 providers/common/ai/docs/operators/llm_branch.rst  | 14 +++++++--
 .../providers/common/ai/operators/llm_branch.py    | 22 ++++++++++++--
 .../unit/common/ai/operators/test_llm_branch.py    | 34 ++++++++++++++++++++++
 3 files changed, 65 insertions(+), 5 deletions(-)

diff --git a/providers/common/ai/docs/operators/llm_branch.rst 
b/providers/common/ai/docs/operators/llm_branch.rst
index 21558b2277f..1ef8c557abc 100644
--- a/providers/common/ai/docs/operators/llm_branch.rst
+++ b/providers/common/ai/docs/operators/llm_branch.rst
@@ -92,14 +92,20 @@ against the downstream task IDs before branching:
     :start-after: [START howto_operator_llm_branch_approval]
     :end-before: [END howto_operator_llm_branch_approval]
 
-Rejecting the review, or letting ``approval_timeout`` expire, **fails** the
-task (``HITLRejectException`` / ``HITLTimeoutError``), so downstream tasks
-end up ``upstream_failed`` rather than skipped.
+Rejecting the review **skips the direct downstream tasks except teardowns**,
+matching
+:class:`~airflow.providers.standard.operators.hitl.ApprovalOperator`. The
+teardown carve-out applies only to rejection: approving branches as usual,
+so a teardown that is not among the chosen branch(es) is skipped like any
+other unselected downstream task. Set ``fail_on_reject=True`` to fail the
+task on rejection instead (generally discouraged). Letting
+``approval_timeout`` expire fails the task (``HITLTimeoutError``).
 
 ``require_approval=True`` requires a string prompt: a decorated callable
 returning a ``Sequence[UserContent]`` raises ``TypeError`` before the LLM
 call.
 
+Apart from ``fail_on_reject``, which is specific to this operator,
 ``approval_timeout`` and the rest of the approval behaviour are inherited
 from :ref:`LLMOperator <howto/operator:llm>`.
 
@@ -133,6 +139,8 @@ Parameters
   means wait indefinitely.  Default ``None``.
 - ``allow_modifications``: If ``True``, the reviewer can change the chosen
   branch(es) before approving.  Default ``False``.
+- ``fail_on_reject``: If ``True``, a rejected review fails the task instead of
+  skipping the downstream tasks.  Generally discouraged.  Default ``False``.
 
 Logging
 -------
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 88c29943259..5de42fb2db6 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
@@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any
 
 from airflow.providers.common.ai.operators.llm import LLMOperator
 from airflow.providers.common.ai.utils.logging import log_run_summary
+from airflow.providers.standard.exceptions import HITLRejectException
 from airflow.providers.standard.operators.branch import BranchMixIn
 
 if TYPE_CHECKING:
@@ -46,6 +47,10 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
     :param system_prompt: System-level instructions for the LLM agent.
     :param allow_multiple_branches: When ``False`` (default) the LLM returns a
         single task ID. When ``True`` the LLM may return one or more task IDs.
+    :param fail_on_reject: If ``True``, a rejected review fails the task
+        instead of skipping the downstream tasks. Generally discouraged,
+        as for 
:class:`~airflow.providers.standard.operators.hitl.ApprovalOperator`.
+        Default ``False``.
     :param agent_params: Additional keyword arguments passed to the pydantic-ai
         ``Agent`` constructor (e.g. ``retries``, ``model_settings``, 
``tools``).
 
@@ -53,7 +58,10 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
     :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
     (``require_approval``, ``approval_timeout``, ``allow_modifications``).
     The task pauses after the LLM chooses the branch(es) and only skips the
-    unselected downstream tasks once a reviewer approves. The review form
+    unselected downstream tasks once a reviewer approves. Rejecting the
+    review skips the direct downstream tasks except teardowns, matching
+    :class:`~airflow.providers.standard.operators.hitl.ApprovalOperator`;
+    set ``fail_on_reject=True`` to fail the task instead. The review form
     lists the valid downstream task IDs; with ``allow_modifications=True``
     the editable choice is rendered as a dropdown of those IDs (single-branch
     mode) or a multi-select of them (``allow_multiple_branches=True``), and
@@ -69,11 +77,13 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
         self,
         *,
         allow_multiple_branches: bool = False,
+        fail_on_reject: bool = False,
         **kwargs: Any,
     ) -> None:
         kwargs.pop("output_type", None)
         super().__init__(**kwargs)
         self.allow_multiple_branches = allow_multiple_branches
+        self.fail_on_reject = fail_on_reject
 
     def execute(self, context: Context) -> str | Iterable[str] | None:
         if self.require_approval:
@@ -133,7 +143,15 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
 
     def execute_complete(self, context: Context, generated_output: str, event: 
dict[str, Any]) -> Any:
         """Resume after human review, validating the reviewed choice before 
branching."""
-        output = super().execute_complete(context, generated_output, event)
+        try:
+            output = super().execute_complete(context, generated_output, event)
+        except HITLRejectException:
+            if self.fail_on_reject:
+                raise
+            self.log.info("Rejected by %s. Skipping downstream tasks...", 
event.get("responded_by_user"))
+            tasks = context["task"].get_direct_relatives(upstream=False)
+            self.skip(ti=context["ti"], tasks=(t for t in tasks if not 
t.is_teardown))
+            return None
         branches = self._parse_reviewed_branches(output)
         selected = {branches} if isinstance(branches, str) else set(branches)
         invalid = selected - self.downstream_task_ids
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 7f2d11bf263..7ff0b9bf83c 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
@@ -26,6 +26,7 @@ from airflow.providers.common.ai.mixins.approval import 
LLMApprovalMixin
 from airflow.providers.common.ai.operators.llm import LLMOperator
 from airflow.providers.common.ai.operators.llm_branch import LLMBranchOperator
 from airflow.providers.common.compat.sdk import Param, ParamValidationError, 
TaskDeferred
+from airflow.providers.standard.exceptions import HITLRejectException
 
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS, 
AIRFLOW_V_3_3_PLUS
 
@@ -397,6 +398,39 @@ class TestLLMBranchOperatorApproval:
         assert result == ["task_a", "task_c"]
         mock_do_branch.assert_called_once_with(ctx, ["task_a", "task_c"])
 
+    @patch.object(LLMBranchOperator, "skip")
+    @patch.object(LLMBranchOperator, "do_branch")
+    def test_execute_complete_reject_skips_downstream_except_teardowns(self, 
mock_do_branch, mock_skip):
+        op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c")
+        op.downstream_task_ids = {"task_a", "cleanup"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": "admin"}
+        task_a = MagicMock(is_teardown=False)
+        cleanup = MagicMock(is_teardown=True)
+        task = MagicMock()
+        task.get_direct_relatives.return_value = [task_a, cleanup]
+        ti = MagicMock()
+        ctx = MagicMock(**{"__getitem__": lambda self, key: {"task": task, 
"ti": ti}[key]})
+
+        result = op.execute_complete(ctx, generated_output="task_a", 
event=event)
+
+        assert result is None
+        task.get_direct_relatives.assert_called_once_with(upstream=False)
+        mock_skip.assert_called_once()
+        assert mock_skip.call_args.kwargs["ti"] is ti
+        assert list(mock_skip.call_args.kwargs["tasks"]) == [task_a]
+        mock_do_branch.assert_not_called()
+
+    @patch.object(LLMBranchOperator, "do_branch")
+    def test_execute_complete_reject_fails_with_fail_on_reject(self, 
mock_do_branch):
+        op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c", 
fail_on_reject=True)
+        op.downstream_task_ids = {"task_a", "task_b"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": "admin"}
+
+        with pytest.raises(HITLRejectException, match="rejected"):
+            op.execute_complete(_make_context(), generated_output="task_a", 
event=event)
+
+        mock_do_branch.assert_not_called()
+
     @patch.object(LLMBranchOperator, "do_branch")
     def test_execute_complete_with_modified_branch(self, mock_do_branch):
         """A reviewer-modified branch is used when it is a valid downstream 
task."""

Reply via email to