This is an automated email from the ASF dual-hosted git repository.
kaxil 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 029cdf8f6cc Return an approved agent answer as `output_type` after
human review (#73904)
029cdf8f6cc is described below
commit 029cdf8f6cce2a1a2729706aaf0b87924c92c66f
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Sep 29 21:15:15 2026 +0100
Return an approved agent answer as `output_type` after human review (#73904)
With enable_hitl_review, AgentOperator ran the approved text through
json.loads unless output_type was a Pydantic model, so an answer of "42"
came back as an int and one that looked like a JSON object as a dict, even
with the default output_type=str. It now uses the same conversion as
LLMOperator's approval path: str passes through unchanged, and any other
output_type is validated from the JSON, falling back to the text a reviewer
edited into something the type rejects.
---
.../airflow/providers/common/ai/operators/agent.py | 15 +++------
.../tests/unit/common/ai/operators/test_agent.py | 36 ++++++++++++++++++++++
2 files changed, 41 insertions(+), 10 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 1b6f5d5f0b7..d6ef9ad8100 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
@@ -849,16 +849,11 @@ class AgentOperator(CancellableAgentRunMixin,
BaseOperator, HITLReviewMixin):
output,
message_history=result.all_messages(),
)
- if isinstance(self.output_type, type) and
issubclass(self.output_type, BaseModel):
- return rehydrate_pydantic_output(
- self.output_type,
- result_str,
- serialize_output=self._serialize_model_output,
- )
- try:
- return json.loads(result_str)
- except (ValueError, TypeError):
- return result_str
+ return rehydrate_pydantic_output(
+ self.output_type,
+ result_str,
+ serialize_output=self._serialize_model_output,
+ )
if self._serialize_model_output and isinstance(output, BaseModel):
output = output.model_dump()
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 73881634b2d..1f26bb9751b 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
@@ -968,6 +968,42 @@ class TestAgentOperatorExecute:
assert result == "Approved output"
+ @pytest.mark.skipif(
+ not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible
with Airflow >= 3.1.0"
+ )
+ @pytest.mark.parametrize(
+ ("output_type", "approved", "expected"),
+ [
+ pytest.param(str, "42", "42", id="str-that-parses-as-a-number"),
+ pytest.param(str, '{"total": 1}', '{"total": 1}',
id="str-that-parses-as-an-object"),
+ pytest.param(list[str], '["a", "b"]', ["a", "b"], id="list"),
+ pytest.param(int, "not a number", "not a number",
id="edit-the-type-rejects"),
+ ],
+ )
+
@patch("airflow.providers.common.ai.operators.agent.AgentOperator.run_hitl_review",
autospec=True)
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_execute_with_hitl_returns_the_approved_output_as_output_type(
+ self, mock_hook_cls, mock_run_hitl, make_mock_run_result, output_type,
approved, expected
+ ):
+ """The approved text comes back as ``output_type``, as it does from
``@task.llm``."""
+ mock_agent = MagicMock(spec=["run_sync", "instrument"])
+ mock_agent.run_sync.return_value = make_mock_run_result("Initial
output")
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+ mock_run_hitl.return_value = approved
+ op = AgentOperator(
+ task_id="test",
+ prompt="Summarize",
+ llm_conn_id="my_llm",
+ output_type=output_type,
+ enable_hitl_review=True,
+ hitl_timeout=timedelta(minutes=5),
+ )
+
+ result = op.execute(context=MagicMock())
+
+ assert result == expected
+ assert type(result) is type(expected)
+
@pytest.mark.skipif(
not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible
with Airflow >= 3.1.0"
)