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 f24e8fe6551 Validate template fields after rendering in common.ai 
operators (#70338)
f24e8fe6551 is described below

commit f24e8fe655166c8258ec4ec51e1361635a152ba6
Author: Stefan Wang <[email protected]>
AuthorDate: Fri Jul 24 00:39:42 2026 -0700

    Validate template fields after rendering in common.ai operators (#70338)
    
    Signed-off-by: 1fanwang <[email protected]>
---
 .../src/airflow/providers/common/ai/operators/agent.py | 13 ++++++-------
 .../providers/common/ai/operators/document_loader.py   | 18 +++++++++---------
 .../ai/tests/unit/common/ai/operators/test_agent.py    | 18 +++++++++++-------
 .../unit/common/ai/operators/test_document_loader.py   | 12 ++++++++----
 scripts/ci/prek/validate_operators_init_exemptions.txt |  2 --
 5 files changed, 34 insertions(+), 29 deletions(-)

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 f83a58f0e2a..5afbe3909b5 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
@@ -293,13 +293,6 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
             # replay. Reject the combination rather than silently 
mis-replaying.
             raise ValueError("durable=True and code_mode=True cannot be used 
together.")
 
-        if message_history is not None and enable_hitl_review:
-            # The post-review transcript is not recoverable today 
(run_hitl_review
-            # returns only the final string), so emitting the pre-review 
transcript
-            # would silently drop the human-approved turns. Block until HITL 
can
-            # surface the final message history.
-            raise ValueError("message_history and enable_hitl_review=True 
cannot be used together.")
-
         self.enable_hitl_review = enable_hitl_review
         self.max_hitl_iterations = max_hitl_iterations
         self.hitl_timeout = hitl_timeout
@@ -424,6 +417,12 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
         )
 
     def execute(self, context: Context) -> Any:
+        # message_history is a template field; validate the combination after 
rendering.
+        if self.message_history is not None and self.enable_hitl_review:
+            # run_hitl_review returns only the final string, so the pre-review 
transcript would drop
+            # the human-approved turns. Block until HITL can surface the final 
message history.
+            raise ValueError("message_history and enable_hitl_review=True 
cannot be used together.")
+
         if self.enable_hitl_review and not isinstance(self.prompt, str):
             raise TypeError(
                 f"{type(self).__name__}: enable_hitl_review=True is not 
supported "
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
 
b/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
index 1c8c6a935b3..c59848c144f 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py
@@ -136,13 +136,6 @@ class DocumentLoaderOperator(BaseOperator):
         **kwargs: Any,
     ) -> None:
         super().__init__(**kwargs)
-        if source_path is not None and source_bytes is not None:
-            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes', not both.")
-        if source_path is None and source_bytes is None:
-            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes'.")
-        if source_bytes is not None and file_type is None:
-            raise ValueError("'file_type' is required when using 
'source_bytes' (e.g. '.pdf').")
-
         self.source_path = source_path
         self.source_conn_id = source_conn_id
         self.source_bytes = source_bytes
@@ -155,12 +148,19 @@ class DocumentLoaderOperator(BaseOperator):
         self.json_text_field = json_text_field
 
     def execute(self, context: Context) -> list[dict[str, Any]]:
+        # source_path/file_type are template fields; validate after rendering, 
not in __init__.
+        if self.source_path is not None and self.source_bytes is not None:
+            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes', not both.")
+        if self.source_path is None and self.source_bytes is None:
+            raise ValueError("Provide exactly one of 'source_path' or 
'source_bytes'.")
+        if self.source_bytes is not None and self.file_type is None:
+            raise ValueError("'file_type' is required when using 
'source_bytes' (e.g. '.pdf').")
         if self.source_bytes is not None:
-            assert self.file_type is not None  # noqa: S101 -- enforced in 
__init__
+            assert self.file_type is not None  # noqa: S101 -- enforced above
             documents = self._parse_bytes(self.source_bytes, self.file_type)
             file_count = 1
         else:
-            assert self.source_path is not None  # noqa: S101 -- enforced in 
__init__
+            assert self.source_path is not None  # noqa: S101 -- enforced above
             files = self._resolve_files(self.source_path)
             if not files:
                 raise FileNotFoundError(f"No files found matching 
'{self.source_path}'.")
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 2631c544138..085f8512df8 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
@@ -874,16 +874,20 @@ class TestAgentOperatorMessageHistory:
         assert kwargs["usage_limits"] is limits
         assert kwargs["message_history"] == []
 
+    @pytest.mark.skipif(
+        not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible 
with Airflow >= 3.1.0"
+    )
     def test_message_history_with_hitl_review_raises(self):
         """message_history cannot be combined with HITL review (post-review 
transcript is lost)."""
+        op = AgentOperator(
+            task_id="t",
+            prompt="run",
+            llm_conn_id="c",
+            message_history=[],
+            enable_hitl_review=True,
+        )
         with pytest.raises(ValueError, match="message_history and 
enable_hitl_review"):
-            AgentOperator(
-                task_id="t",
-                prompt="run",
-                llm_conn_id="c",
-                message_history=[],
-                enable_hitl_review=True,
-            )
+            op.execute(context={})
 
     @patch("pydantic_ai.models.wrapper.infer_model", side_effect=lambda m: m)
     @patch("pydantic_ai.models.infer_model", autospec=True)
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py
index 0a949c19010..575dbaed0a4 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py
@@ -50,20 +50,24 @@ class TestDocumentLoaderInit:
         assert "source_bytes" not in op.template_fields
 
     def test_both_sources_raises(self):
+        op = DocumentLoaderOperator(task_id="test", 
source_path="/tmp/file.txt", source_bytes=b"hello")
         with pytest.raises(ValueError, match="not both"):
-            DocumentLoaderOperator(task_id="test", 
source_path="/tmp/file.txt", source_bytes=b"hello")
+            op.execute(context={})
 
     def test_neither_source_raises(self):
+        op = DocumentLoaderOperator(task_id="test")
         with pytest.raises(ValueError, match="Provide exactly one"):
-            DocumentLoaderOperator(task_id="test")
+            op.execute(context={})
 
     def test_source_bytes_without_file_type_raises(self):
+        op = DocumentLoaderOperator(task_id="test", source_bytes=b"hello")
         with pytest.raises(ValueError, match="file_type"):
-            DocumentLoaderOperator(task_id="test", source_bytes=b"hello")
+            op.execute(context={})
 
     def test_empty_bytes_without_file_type_raises(self):
+        op = DocumentLoaderOperator(task_id="test", source_bytes=b"")
         with pytest.raises(ValueError, match="file_type"):
-            DocumentLoaderOperator(task_id="test", source_bytes=b"")
+            op.execute(context={})
 
 
 class TestTextParser:
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt 
b/scripts/ci/prek/validate_operators_init_exemptions.txt
index 0f5c1e5e263..ae99a1b3369 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -30,8 +30,6 @@ 
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/kueue.
 
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py::KubernetesPodOperator
 
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/resource.py::KubernetesResourceBaseOperator
 
providers/cohere/src/airflow/providers/cohere/operators/embedding.py::CohereEmbeddingOperator
-providers/common/ai/src/airflow/providers/common/ai/operators/agent.py::AgentOperator
-providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py::DocumentLoaderOperator
 
providers/databricks/src/airflow/providers/databricks/operators/databricks_repos.py::DatabricksReposCreateOperator
 
providers/databricks/src/airflow/providers/databricks/operators/databricks_repos.py::DatabricksReposDeleteOperator
 
providers/databricks/src/airflow/providers/databricks/operators/databricks_repos.py::DatabricksReposUpdateOperator

Reply via email to