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 bbb3c066109 Support require_approval in LLMBranchOperator (#70651)
bbb3c066109 is described below
commit bbb3c0661094ceeec1e549f5d6aa8b0de741b9cc
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Tue Aug 4 12:37:07 2026 +0900
Support require_approval in LLMBranchOperator (#70651)
* Support require_approval in LLMBranchOperator
* Improve LLMBranchOperator approval review form
* List valid branches above the fenced review prompt
* Fail LLM branch runs that select no branches
---
providers/common/ai/docs/operators/llm_branch.rst | 34 +++
.../providers/common/ai/decorators/llm_branch.py | 11 +-
.../common/ai/example_dags/example_llm_branch.py | 35 +++
.../airflow/providers/common/ai/mixins/approval.py | 30 +-
.../airflow/providers/common/ai/operators/llm.py | 9 +-
.../providers/common/ai/operators/llm_branch.py | 72 ++++-
.../providers/common/ai/operators/llm_sql.py | 9 +-
.../unit/common/ai/decorators/test_llm_branch.py | 19 ++
.../tests/unit/common/ai/mixins/test_approval.py | 12 +
.../unit/common/ai/operators/test_llm_branch.py | 314 ++++++++++++++++++++-
10 files changed, 510 insertions(+), 35 deletions(-)
diff --git a/providers/common/ai/docs/operators/llm_branch.rst
b/providers/common/ai/docs/operators/llm_branch.rst
index 42db970427a..52affb9af86 100644
--- a/providers/common/ai/docs/operators/llm_branch.rst
+++ b/providers/common/ai/docs/operators/llm_branch.rst
@@ -75,6 +75,34 @@ With multiple branches:
:start-after: [START howto_decorator_llm_branch_multi]
:end-before: [END howto_decorator_llm_branch_multi]
+Human-in-the-Loop Approval
+--------------------------
+
+Set ``require_approval=True`` to pause the task after the LLM chooses the
+branch(es) and wait for a human reviewer to approve the choice before any
+downstream task is skipped. The review form shows the LLM's choice and the
+valid downstream task IDs. When ``allow_modifications=True``, the reviewer
+can also change the choice — rendered as a dropdown of the downstream task
+IDs, or a free-text JSON list of task IDs (e.g. ``["task_a", "task_b"]``)
+with ``allow_multiple_branches=True``. The reviewed branch(es) are validated
+against the downstream task IDs before branching:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_branch.py
+ :language: python
+ :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.
+
+``require_approval=True`` requires a string prompt: a decorated callable
+returning a ``Sequence[UserContent]`` raises ``TypeError`` before the LLM
+call.
+
+``approval_timeout`` and the rest of the approval behaviour are inherited
+from :ref:`LLMOperator <howto/operator:llm>`.
+
How It Works
------------
@@ -99,6 +127,12 @@ Parameters
task ID. When ``True`` the LLM may return one or more task IDs.
- ``agent_params``: Additional keyword arguments passed to the pydantic-ai
``Agent``
constructor (e.g. ``retries``, ``model_settings``). Supports Jinja
templating.
+- ``require_approval``: If ``True``, the task pauses after the LLM chooses the
+ branch(es) and waits for human review before branching. Default ``False``.
+- ``approval_timeout``: Maximum time to wait for a review (``timedelta``).
``None``
+ means wait indefinitely. Default ``None``.
+- ``allow_modifications``: If ``True``, the reviewer can change the chosen
+ branch(es) before approving. Default ``False``.
Logging
-------
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py
b/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py
index 664036ae135..f56f1c4ac0d 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py
@@ -28,7 +28,10 @@ from collections.abc import Callable, Collection, Mapping,
Sequence
from typing import TYPE_CHECKING, Any, ClassVar
from airflow.providers.common.ai.operators.llm_branch import LLMBranchOperator
-from airflow.providers.common.ai.utils.validation import validate_prompt
+from airflow.providers.common.ai.utils.validation import (
+ reject_sequence_with_unsupported_feature,
+ validate_prompt,
+)
from airflow.providers.common.compat.sdk import (
DecoratedOperator,
TaskDecorator,
@@ -89,6 +92,12 @@ class _LLMBranchDecoratedOperator(DecoratedOperator,
LLMBranchOperator):
self.prompt = self.python_callable(*self.op_args, **kwargs)
validate_prompt(self.prompt, decorator_name="@task.llm_branch")
+ reject_sequence_with_unsupported_feature(
+ self.prompt,
+ decorator_name="@task.llm_branch",
+ feature_name="require_approval",
+ feature_enabled=self.require_approval,
+ )
self.render_template_fields(context)
return LLMBranchOperator.execute(self, context)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_branch.py
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_branch.py
index 9dceb685071..c94e984a011 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_branch.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_branch.py
@@ -18,6 +18,8 @@
from __future__ import annotations
+from datetime import timedelta
+
from airflow.providers.common.ai.operators.llm_branch import LLMBranchOperator
from airflow.providers.common.compat.sdk import dag, task
@@ -150,3 +152,36 @@ def example_llm_branch_decorator_multi():
# [END howto_decorator_llm_branch_multi]
example_llm_branch_decorator_multi()
+
+
+# [START howto_operator_llm_branch_approval]
+@dag(tags=["example"])
+def example_llm_branch_approval():
+ route = LLMBranchOperator(
+ task_id="route_with_approval",
+ prompt="User says: 'I was charged twice for my subscription.'",
+ llm_conn_id="pydanticai_default",
+ system_prompt="Route support tickets to the right team.",
+ require_approval=True,
+ approval_timeout=timedelta(hours=24),
+ allow_modifications=True,
+ )
+
+ @task
+ def handle_billing():
+ return "Handling billing issue"
+
+ @task
+ def handle_auth():
+ return "Handling auth issue"
+
+ @task
+ def handle_general():
+ return "Handling general issue"
+
+ route >> [handle_billing(), handle_auth(), handle_general()]
+
+
+# [END howto_operator_llm_branch_approval]
+
+example_llm_branch_approval()
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
index 9f045c02548..21c2e4c9de4 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
@@ -45,6 +45,8 @@ class DeferForApprovalProtocol(Protocol):
task_id: str
defer: Any
+ def validate_approval_prompt(self) -> None: ...
+
class LLMApprovalMixin:
"""
@@ -70,6 +72,18 @@ class LLMApprovalMixin:
APPROVE = "Approve"
REJECT = "Reject"
+ def validate_approval_prompt(self: DeferForApprovalProtocol) -> None:
+ """Fail fast when the prompt cannot be rendered as text in the
approval review body."""
+ if not isinstance(self.prompt, str):
+ raise TypeError(
+ f"{type(self).__name__}: require_approval=True is not
supported "
+ f"with a non-string prompt (got {type(self.prompt).__name__}).
"
+ "The approval review body renders the prompt as text; passing
a "
+ "Sequence[UserContent] would expose object reprs (and any
embedded "
+ "bytes) in the human review UI. Return a str prompt, or
disable "
+ "require_approval."
+ )
+
def defer_for_approval(
self: DeferForApprovalProtocol,
context: Context,
@@ -77,6 +91,7 @@ class LLMApprovalMixin:
*,
subject: str | None = None,
body: str | None = None,
+ modification_schema: dict[str, Any] | None = None,
) -> None:
"""
Write HITL detail, then pause the task for human review.
@@ -91,19 +106,16 @@ class LLMApprovalMixin:
Defaults to ``"Review output for task `<task_id>`"``.
:param body: Markdown body shown below the headline.
Defaults to the prompt and output wrapped in a code block.
+ :param modification_schema: JSON schema for the editable ``output``
param
+ when ``allow_modifications=True``. Defaults to ``{"type":
"string"}``.
+ Pass e.g. ``{"type": "string", "enum": [...]}`` to render a
dropdown
+ of valid values in the review form.
"""
from airflow.providers.standard.triggers.hitl import HITLTrigger
from airflow.sdk.execution_time.hitl import upsert_hitl_detail
from airflow.sdk.timezone import utcnow
- if not isinstance(self.prompt, str):
- raise TypeError(
- "require_approval=True is not supported with a non-string
prompt. "
- "The approval review body renders the prompt as text; passing
a "
- "Sequence[UserContent] would expose object reprs (and any
embedded "
- "bytes) in the human review UI. Return a str prompt, or
disable "
- "require_approval."
- )
+ self.validate_approval_prompt()
if isinstance(output, BaseModel):
output = output.model_dump_json()
@@ -125,7 +137,7 @@ class LLMApprovalMixin:
"output": {
"value": output,
"description": "Edit the output before approving
(optional).",
- "schema": {"type": "string"},
+ "schema": modification_schema or {"type": "string"},
},
}
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 95f33bffffa..36cd616c1b3 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
@@ -155,13 +155,8 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
return PydanticAIHook.get_hook(self.llm_conn_id,
hook_params=hook_params)
def execute(self, context: Context) -> Any:
- if self.require_approval and not isinstance(self.prompt, str):
- raise TypeError(
- f"{type(self).__name__}: require_approval=True is not
supported "
- f"with a non-string prompt (got {type(self.prompt).__name__}).
"
- f"The approval review body renders the prompt as text. Return
a "
- f"str prompt, or disable require_approval."
- )
+ if self.require_approval:
+ self.validate_approval_prompt() # type: ignore[misc]
agent: Agent[object, Any] = self.llm_hook.create_agent(
output_type=self.output_type, instructions=self.system_prompt,
**self.agent_params
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 b6a25a7811e..620b36e6809 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
@@ -18,6 +18,7 @@
from __future__ import annotations
+import json
from collections.abc import Iterable, Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any
@@ -47,6 +48,17 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
single task ID. When ``True`` the LLM may return one or more task IDs.
:param agent_params: Additional keyword arguments passed to the pydantic-ai
``Agent`` constructor (e.g. ``retries``, ``model_settings``,
``tools``).
+
+ Human-in-the-Loop approval parameters are inherited from
+ :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
+ 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 free-text JSON list (``allow_multiple_branches=True``), and
+ the reviewed branch(es) are validated against the downstream task IDs
+ before branching.
"""
inherits_from_skipmixin = True
@@ -60,12 +72,13 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
**kwargs: Any,
) -> None:
kwargs.pop("output_type", None)
- if kwargs.get("require_approval"):
- raise ValueError("require_approval=True is not supported by
LLMBranchOperator.")
super().__init__(**kwargs)
self.allow_multiple_branches = allow_multiple_branches
def execute(self, context: Context) -> str | Iterable[str] | None:
+ if self.require_approval:
+ self.validate_approval_prompt() # type: ignore[misc]
+
if not self.downstream_task_ids:
raise ValueError(
f"{self.task_id!r} has no downstream tasks. "
@@ -95,4 +108,59 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
else:
branches = str(output)
+ if not branches:
+ raise ValueError(
+ f"LLM selected no branches for {self.task_id!r}, which would
skip every downstream task."
+ )
+
+ if self.require_approval:
+ choices = sorted(self.downstream_task_ids)
+ chosen = branches if isinstance(branches, str) else
json.dumps(branches)
+ body = (
+ f"Valid branches: {', '.join(f'`{c}`' for c in choices)}\n\n"
+ f"```\nPrompt: {self.prompt}\n\nChosen branch(es):
{chosen}\n```"
+ )
+ modification_schema = (
+ None if self.allow_multiple_branches else {"type": "string",
"enum": choices}
+ )
+ self.defer_for_approval( # type: ignore[misc]
+ context, branches, body=body,
modification_schema=modification_schema
+ )
+
return self.do_branch(context, branches)
+
+ 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)
+ branches = self._parse_reviewed_branches(output)
+ selected = {branches} if isinstance(branches, str) else set(branches)
+ invalid = selected - self.downstream_task_ids
+ if invalid:
+ raise ValueError(
+ f"Reviewed branch(es) {sorted(invalid)} are not downstream
tasks of "
+ f"{self.task_id!r}. Valid choices:
{sorted(self.downstream_task_ids)}."
+ )
+ return self.do_branch(context, branches)
+
+ def _parse_reviewed_branches(self, output: str) -> str | list[str]:
+ if not self.allow_multiple_branches:
+ return output
+ try:
+ branches = json.loads(output)
+ except json.JSONDecodeError as e:
+ raise ValueError(
+ f"Reviewed output {output!r} is not valid JSON. With "
+ f"allow_multiple_branches=True the reviewed output must be a "
+ f'JSON list of task IDs, e.g. ["task_a", "task_b"].'
+ ) from e
+ if not isinstance(branches, list) or not all(isinstance(b, str) for b
in branches):
+ raise ValueError(
+ f"Reviewed output {output!r} must be a JSON list of task ID
strings, "
+ f'e.g. ["task_a", "task_b"].'
+ )
+ if not branches:
+ raise ValueError(
+ "Reviewed output selects no branches, which would skip every
downstream "
+ "task. Select at least one task ID, or reject the review
instead."
+ )
+ return branches
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 e8a3b327b42..262d52ff8d0 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
@@ -134,13 +134,8 @@ class LLMSQLQueryOperator(LLMOperator):
return hook
def execute(self, context: Context) -> str:
- if self.require_approval and not isinstance(self.prompt, str):
- raise TypeError(
- f"{type(self).__name__}: require_approval=True is not
supported "
- f"with a non-string prompt (got {type(self.prompt).__name__}).
"
- f"The approval review body renders the prompt as text. Return
a "
- f"str prompt, or disable require_approval."
- )
+ if self.require_approval:
+ self.validate_approval_prompt() # type: ignore[misc]
schema_info = self._get_schema_context()
diff --git
a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
index 37238bff00b..2243315dcf3 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_llm_branch.py
@@ -83,6 +83,25 @@ class TestLLMBranchDecoratedOperator:
with pytest.raises(TypeError, match="must be"):
op.execute(context={})
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def
test_sequence_prompt_with_require_approval_raises_before_run_sync(self,
mock_hook_cls):
+ """Sequence prompt + require_approval=True fails before the agent
runs."""
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = _LLMBranchDecoratedOperator(
+ task_id="test",
+ python_callable=lambda: ["x",
ImageUrl(url="https://example.com/x.png")],
+ llm_conn_id="my_llm",
+ require_approval=True,
+ )
+ op.downstream_task_ids = {"positive"}
+
+ with pytest.raises(TypeError, match="require_approval=True"):
+ op.execute(context={})
+
+ mock_agent.run_sync.assert_not_called()
+
@patch.object(LLMBranchOperator, "do_branch")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
def test_execute_accepts_sequence_prompt(self, mock_hook_cls,
mock_do_branch):
diff --git a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
index 46120b3c3c3..23c97bd2470 100644
--- a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
+++ b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
@@ -146,6 +146,18 @@ class TestDeferForApproval:
assert param["value"] == "draft text"
assert param["schema"] == {"type": "string"}
+ @patch(HITL_TRIGGER_PATH, autospec=True)
+ @patch(UPSERT_HITL_PATH)
+ def test_modification_schema_overrides_output_param_schema(
+ self, mock_upsert, mock_trigger_cls, approval_op_with_modifications,
context
+ ):
+ schema = {"type": "string", "enum": ["task_a", "task_b"]}
+
+ approval_op_with_modifications.defer_for_approval(context, "task_a",
modification_schema=schema)
+
+ param = mock_upsert.call_args[1]["params"]["output"]
+ assert param["schema"] == schema
+
@patch(HITL_TRIGGER_PATH, autospec=True)
@patch(UPSERT_HITL_PATH)
def test_no_modifications_params_empty(self, mock_upsert,
mock_trigger_cls, approval_op, context):
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 fccf5e2f837..78107daf138 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
@@ -18,11 +18,24 @@ from __future__ import annotations
from enum import Enum
from unittest.mock import MagicMock, patch
+from uuid import uuid4
import pytest
+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 TaskDeferred
+
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS,
AIRFLOW_V_3_3_PLUS
+
+if AIRFLOW_V_3_3_PLUS:
+ # On 3.3+ cores require_approval pauses the task in AWAITING_INPUT; older
cores defer to
+ # HITLTrigger. Both signals carry method_name/kwargs/timeout, so the
approval tests assert
+ # against whichever pause signal the running core uses.
+ from airflow.sdk.exceptions import TaskAwaitingInput as ApprovalPauseSignal
+else:
+ ApprovalPauseSignal = TaskDeferred # type: ignore[assignment, misc]
def _make_mock_run_result(output):
@@ -54,15 +67,6 @@ class TestLLMBranchOperator:
# the real output_type is built dynamically from downstream_task_ids
assert op.output_type is str
- def test_require_approval_rejected(self):
- with pytest.raises(ValueError, match="require_approval=True is not
supported"):
- LLMBranchOperator(
- task_id="test",
- prompt="pick a branch",
- llm_conn_id="my_llm",
- require_approval=True,
- )
-
@patch.object(LLMBranchOperator, "do_branch")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
def test_execute_single_branch(self, mock_hook_cls, mock_do_branch):
@@ -117,6 +121,26 @@ class TestLLMBranchOperator:
assert result == ["task_a", "task_c"]
mock_do_branch.assert_called_once_with(ctx, ["task_a", "task_c"])
+ @patch.object(LLMBranchOperator, "do_branch")
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_rejects_empty_branch_selection(self, mock_hook_cls,
mock_do_branch):
+ """LLM returning an empty list fails instead of skipping every
downstream task."""
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_agent.run_sync.return_value = _make_mock_run_result([])
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMBranchOperator(
+ task_id="test",
+ prompt="Pick branches",
+ llm_conn_id="my_llm",
+ allow_multiple_branches=True,
+ )
+ op.downstream_task_ids = {"task_a", "task_b"}
+
+ with pytest.raises(ValueError, match="selected no branches"):
+ op.execute(MagicMock())
+ mock_do_branch.assert_not_called()
+
@patch.object(LLMBranchOperator, "do_branch")
@patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
def test_system_prompt_forwarded(self, mock_hook_cls, mock_do_branch):
@@ -173,3 +197,275 @@ class TestLLMBranchOperator:
)
with pytest.raises(ValueError, match="no downstream tasks"):
op.execute(MagicMock())
+
+
+def _make_context(ti_id=None):
+ ti_id = ti_id or uuid4()
+ ti = MagicMock()
+ ti.id = ti_id
+ return MagicMock(**{"__getitem__": lambda self, key: {"task_instance":
ti}[key]})
+
+
[email protected](
+ not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with
Airflow >= 3.1.0"
+)
+class TestLLMBranchOperatorApproval:
+ """Tests for LLMBranchOperator with require_approval=True
(LLMApprovalMixin integration)."""
+
+ def test_inherits_llm_approval_mixin(self):
+ assert issubclass(LLMBranchOperator, LLMApprovalMixin)
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ @patch("airflow.providers.standard.triggers.hitl.HITLTrigger",
autospec=True)
+ @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_with_approval_pauses_before_branching(
+ self, mock_hook_cls, mock_upsert, mock_trigger_cls, mock_do_branch
+ ):
+ """When require_approval=True, execute() pauses after the LLM choice,
before do_branch."""
+ downstream_enum = Enum("DownstreamTasks", {"task_a": "task_a",
"task_b": "task_b"})
+
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_agent.run_sync.return_value =
_make_mock_run_result(downstream_enum.task_a)
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMBranchOperator(
+ task_id="branch_approval",
+ prompt="Pick a branch",
+ llm_conn_id="my_llm",
+ require_approval=True,
+ )
+ op.downstream_task_ids = {"task_a", "task_b"}
+
+ with pytest.raises(ApprovalPauseSignal) as exc_info:
+ op.execute(_make_context())
+
+ assert exc_info.value.method_name == "execute_complete"
+ assert exc_info.value.kwargs["generated_output"] == "task_a"
+ mock_upsert.assert_called_once()
+ mock_do_branch.assert_not_called()
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ @patch("airflow.providers.standard.triggers.hitl.HITLTrigger",
autospec=True)
+ @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_with_approval_serializes_multiple_branches(
+ self, mock_hook_cls, mock_upsert, mock_trigger_cls, mock_do_branch
+ ):
+ """With allow_multiple_branches=True the choice is deferred as a JSON
list."""
+ downstream_enum = Enum("DownstreamTasks", {"task_a": "task_a",
"task_c": "task_c"})
+
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_agent.run_sync.return_value = _make_mock_run_result(
+ [downstream_enum.task_a, downstream_enum.task_c]
+ )
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMBranchOperator(
+ task_id="branch_approval_multi",
+ prompt="Pick branches",
+ llm_conn_id="my_llm",
+ allow_multiple_branches=True,
+ require_approval=True,
+ )
+ op.downstream_task_ids = {"task_a", "task_b", "task_c"}
+
+ with pytest.raises(ApprovalPauseSignal) as exc_info:
+ op.execute(_make_context())
+
+ assert exc_info.value.kwargs["generated_output"] ==
'["task_a","task_c"]'
+ mock_do_branch.assert_not_called()
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ @patch("airflow.providers.standard.triggers.hitl.HITLTrigger",
autospec=True)
+ @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_review_form_lists_choices_and_renders_enum_dropdown(
+ self, mock_hook_cls, mock_upsert, mock_trigger_cls, mock_do_branch
+ ):
+ """The review body lists the valid branches and the editable param is
an enum dropdown."""
+ downstream_enum = Enum("DownstreamTasks", {"task_a": "task_a",
"task_b": "task_b"})
+
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_agent.run_sync.return_value =
_make_mock_run_result(downstream_enum.task_a)
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMBranchOperator(
+ task_id="branch_approval",
+ prompt="Pick a branch",
+ llm_conn_id="my_llm",
+ require_approval=True,
+ allow_modifications=True,
+ )
+ op.downstream_task_ids = {"task_b", "task_a"}
+
+ with pytest.raises(ApprovalPauseSignal):
+ op.execute(_make_context())
+
+ call_kwargs = mock_upsert.call_args.kwargs
+ assert call_kwargs["body"].startswith("Valid branches: `task_a`,
`task_b`")
+ assert call_kwargs["params"]["output"]["schema"] == {
+ "type": "string",
+ "enum": ["task_a", "task_b"],
+ }
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ @patch("airflow.providers.standard.triggers.hitl.HITLTrigger",
autospec=True)
+ @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_review_form_multi_branch_keeps_string_schema(
+ self, mock_hook_cls, mock_upsert, mock_trigger_cls, mock_do_branch
+ ):
+ """With allow_multiple_branches the editable param stays free-text
(JSON list)."""
+ downstream_enum = Enum("DownstreamTasks", {"task_a": "task_a",
"task_b": "task_b"})
+
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_agent.run_sync.return_value =
_make_mock_run_result([downstream_enum.task_a])
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMBranchOperator(
+ task_id="branch_approval_multi",
+ prompt="Pick branches",
+ llm_conn_id="my_llm",
+ allow_multiple_branches=True,
+ require_approval=True,
+ allow_modifications=True,
+ )
+ op.downstream_task_ids = {"task_a", "task_b"}
+
+ with pytest.raises(ApprovalPauseSignal):
+ op.execute(_make_context())
+
+ call_kwargs = mock_upsert.call_args.kwargs
+ assert "Valid branches: `task_a`, `task_b`" in call_kwargs["body"]
+ assert call_kwargs["params"]["output"]["schema"] == {"type": "string"}
+
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_rejects_sequence_prompt_with_require_approval(self,
mock_hook_cls):
+ """Non-string prompt + require_approval=True fails before the agent
runs."""
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMBranchOperator(
+ task_id="test",
+ prompt=["describe", b"bytes"], # type: ignore[arg-type]
+ llm_conn_id="my_llm",
+ require_approval=True,
+ )
+ op.downstream_task_ids = {"task_a"}
+
+ with pytest.raises(TypeError, match="require_approval=True"):
+ op.execute(_make_context())
+
+ mock_agent.run_sync.assert_not_called()
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ def test_execute_complete_approved_single_branch(self, mock_do_branch):
+ """execute_complete branches into the approved task."""
+ mock_do_branch.return_value = "task_a"
+ op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c")
+ op.downstream_task_ids = {"task_a", "task_b"}
+ event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+ ctx = _make_context()
+
+ result = op.execute_complete(ctx, generated_output="task_a",
event=event)
+
+ assert result == "task_a"
+ mock_do_branch.assert_called_once_with(ctx, "task_a")
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ def test_execute_complete_approved_multiple_branches(self, mock_do_branch):
+ """execute_complete parses the JSON list back before branching."""
+ mock_do_branch.return_value = ["task_a", "task_c"]
+ op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c",
allow_multiple_branches=True)
+ op.downstream_task_ids = {"task_a", "task_b", "task_c"}
+ event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+ ctx = _make_context()
+
+ result = op.execute_complete(ctx,
generated_output='["task_a","task_c"]', event=event)
+
+ assert result == ["task_a", "task_c"]
+ mock_do_branch.assert_called_once_with(ctx, ["task_a", "task_c"])
+
+ @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."""
+ mock_do_branch.return_value = "task_b"
+ op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c",
allow_modifications=True)
+ op.downstream_task_ids = {"task_a", "task_b"}
+ event = {
+ "chosen_options": ["Approve"],
+ "responded_by_user": "admin",
+ "params_input": {"output": "task_b"},
+ }
+ ctx = _make_context()
+
+ result = op.execute_complete(ctx, generated_output="task_a",
event=event)
+
+ assert result == "task_b"
+ mock_do_branch.assert_called_once_with(ctx, "task_b")
+
+ @patch.object(LLMBranchOperator, "do_branch")
+ def test_execute_complete_rejects_invalid_modified_branch(self,
mock_do_branch):
+ """A reviewer-modified branch outside downstream_task_ids fails
validation."""
+ op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c",
allow_modifications=True)
+ op.downstream_task_ids = {"task_a", "task_b"}
+ event = {
+ "chosen_options": ["Approve"],
+ "responded_by_user": "admin",
+ "params_input": {"output": "task_x"},
+ }
+
+ with pytest.raises(ValueError, match="not downstream tasks"):
+ 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_rejects_empty_branch_list(self, mock_do_branch):
+ """A reviewed empty list would skip every downstream task and must be
rejected."""
+ op = LLMBranchOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ allow_multiple_branches=True,
+ allow_modifications=True,
+ )
+ op.downstream_task_ids = {"task_a", "task_b"}
+ event = {
+ "chosen_options": ["Approve"],
+ "responded_by_user": "admin",
+ "params_input": {"output": "[]"},
+ }
+
+ with pytest.raises(ValueError, match="selects no branches"):
+ op.execute_complete(_make_context(),
generated_output='["task_a"]', event=event)
+
+ mock_do_branch.assert_not_called()
+
+ @pytest.mark.parametrize(
+ "modified",
+ ["not json", '{"task_a": 1}', '["task_a", 2]'],
+ ids=["malformed-json", "not-a-list", "non-string-item"],
+ )
+ @patch.object(LLMBranchOperator, "do_branch")
+ def test_execute_complete_rejects_invalid_multi_branch_shapes(self,
mock_do_branch, modified):
+ """With allow_multiple_branches=True the reviewed output must be a
JSON list of strings."""
+ op = LLMBranchOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ allow_multiple_branches=True,
+ allow_modifications=True,
+ )
+ op.downstream_task_ids = {"task_a", "task_b"}
+ event = {
+ "chosen_options": ["Approve"],
+ "responded_by_user": "admin",
+ "params_input": {"output": modified},
+ }
+
+ with pytest.raises(ValueError, match="JSON list"):
+ op.execute_complete(_make_context(),
generated_output='["task_a"]', event=event)
+
+ mock_do_branch.assert_not_called()