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 428e873c9fc Support full downstream skip on LLM branch reject (#72183)
428e873c9fc is described below
commit 428e873c9fc0ce164dadf42bd51f8223fc9c60a0
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Wed Sep 9 21:53:24 2026 +0800
Support full downstream skip on LLM branch reject (#72183)
* Support full downstream skip on LLM branch reject
Rejecting a branch review only skipped the direct downstream tasks, so a
task with a permissive trigger rule further down the chain still ran even
though the reviewer had turned the branch down.
* Cover reject skip on a real Dag and document approval precondition
Reviewers asked for the transitive-plus-teardown skip to be pinned
against a real Dag topology rather than mocked getters, and for the
docs to state that the reject flags only apply with require_approval.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fh4nN2o42JdSjvhWyLEpW6
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
providers/common/ai/docs/operators/llm_branch.rst | 19 ++++++----
.../providers/common/ai/operators/llm_branch.py | 19 ++++++++--
.../unit/common/ai/operators/test_llm_branch.py | 42 ++++++++++++++++------
3 files changed, 60 insertions(+), 20 deletions(-)
diff --git a/providers/common/ai/docs/operators/llm_branch.rst
b/providers/common/ai/docs/operators/llm_branch.rst
index 1ef8c557abc..d1f11a7309c 100644
--- a/providers/common/ai/docs/operators/llm_branch.rst
+++ b/providers/common/ai/docs/operators/llm_branch.rst
@@ -98,16 +98,19 @@ matching
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``).
+task on rejection instead (generally discouraged), or
+``ignore_downstream_trigger_rules=True`` to skip every downstream task rather
+than only the direct ones, so a task whose trigger rule would still run it is
+skipped too. 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>`.
+Apart from ``fail_on_reject`` and ``ignore_downstream_trigger_rules``, which
+are specific to this operator, ``approval_timeout`` and the rest of the
+approval behaviour are inherited from :ref:`LLMOperator <howto/operator:llm>`.
How It Works
------------
@@ -140,7 +143,11 @@ Parameters
- ``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``.
+ skipping the downstream tasks. Generally discouraged. Only takes effect
+ with ``require_approval=True``. Default ``False``.
+- ``ignore_downstream_trigger_rules``: If ``True``, a rejected review skips
every
+ downstream task rather than only the direct ones. Only takes effect with
+ ``require_approval=True``. 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 5de42fb2db6..b3970596bf6 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
@@ -50,7 +50,11 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
: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``.
+ Only takes effect with ``require_approval=True``. Default ``False``.
+ :param ignore_downstream_trigger_rules: If ``True``, a rejected review
skips
+ every downstream task rather than only the direct ones, so a task whose
+ trigger rule would still run it is skipped too. Only takes effect with
+ ``require_approval=True``. Default ``False``.
:param agent_params: Additional keyword arguments passed to the pydantic-ai
``Agent`` constructor (e.g. ``retries``, ``model_settings``,
``tools``).
@@ -61,7 +65,9 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
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
+ set ``fail_on_reject=True`` to fail the task instead, or
+ ``ignore_downstream_trigger_rules=True`` to skip every downstream task
+ rather than only the direct ones. 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
@@ -78,12 +84,14 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
*,
allow_multiple_branches: bool = False,
fail_on_reject: bool = False,
+ ignore_downstream_trigger_rules: 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
+ self.ignore_downstream_trigger_rules = ignore_downstream_trigger_rules
def execute(self, context: Context) -> str | Iterable[str] | None:
if self.require_approval:
@@ -149,7 +157,12 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
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)
+ task = context["task"]
+ tasks = (
+ task.get_flat_relatives(upstream=False)
+ if self.ignore_downstream_trigger_rules
+ else 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)
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 7ff0b9bf83c..94f09c7046d 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
@@ -27,6 +27,7 @@ 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 airflow.providers.standard.operators.empty import EmptyOperator
from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS,
AIRFLOW_V_3_3_PLUS
@@ -398,26 +399,45 @@ class TestLLMBranchOperatorApproval:
assert result == ["task_a", "task_c"]
mock_do_branch.assert_called_once_with(ctx, ["task_a", "task_c"])
+ @pytest.mark.db_test
+ @pytest.mark.parametrize(
+ ("ignore_downstream_trigger_rules", "with_teardown", "expected"),
+ [
+ (False, True, {"op2"}),
+ (False, False, {"op2"}),
+ (True, True, {"op2", "op3"}),
+ (True, False, {"op2", "op3", "op4"}),
+ ],
+ )
@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"}
+ def test_execute_complete_reject_skips_downstream_except_teardowns(
+ self, mock_do_branch, mock_skip, dag_maker,
ignore_downstream_trigger_rules, with_teardown, expected
+ ):
+ with dag_maker(serialized=True):
+ op1 = LLMBranchOperator(
+ task_id="op1",
+ prompt="p",
+ llm_conn_id="c",
+ require_approval=True,
+
ignore_downstream_trigger_rules=ignore_downstream_trigger_rules,
+ )
+ op2 = EmptyOperator(task_id="op2")
+ op3 = EmptyOperator(task_id="op3")
+ op4 = EmptyOperator(task_id="op4")
+ if with_teardown:
+ op4.as_teardown()
+ op1 >> op2 >> op3 >> op4
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]})
+ ctx = MagicMock(**{"__getitem__": lambda self, key: {"task": op1,
"ti": ti}[key]})
- result = op.execute_complete(ctx, generated_output="task_a",
event=event)
+ result = op1.execute_complete(ctx, generated_output="op2", 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]
+ assert {t.task_id for t in mock_skip.call_args.kwargs["tasks"]} ==
expected
mock_do_branch.assert_not_called()
@patch.object(LLMBranchOperator, "do_branch")