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

pankajastro 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 31bb7edc69c Add emit_message_history to record an agent transcript 
without seeding it (#74811)
31bb7edc69c is described below

commit 31bb7edc69c410c9f58918f460a0fdf4a02f9c96
Author: Pankaj Singh <[email protected]>
AuthorDate: Tue Oct 20 17:11:07 2026 +0530

    Add emit_message_history to record an agent transcript without seeding it 
(#74811)
    
    * Add emit_message_history to record an agent transcript without seeding it
    
    Single-turn agents that want their transcript on XCom had to pass
    message_history=[], a parameter for seeding a conversation, not for
    observing a run. A dedicated flag states that intent directly, and
    do_xcom_push=False now also suppresses the transcript push, as it does
    the other XCom pushes.
    
    Co-Authored-By: Claude <[email protected]>
    
    * Give emit_message_history its own switch independent of do_xcom_push
    
    Gating the transcript push on do_xcom_push also stopped it for
    message_history users, narrowing a stable feature. A tri-state
    emit_message_history defaults to whether the run is seeded, so existing
    sessions keep pushing, a single-turn run can opt in, and a seeded run
    can opt out.
    
    Co-Authored-By: Claude <[email protected]>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 providers/common/ai/docs/message_history.rst       | 10 ++++-
 providers/common/ai/docs/operators/agent.rst       |  3 ++
 providers/common/ai/docs/stability.rst             |  4 ++
 providers/common/ai/docs/troubleshooting.rst       |  2 +-
 .../airflow/providers/common/ai/operators/agent.py | 14 ++++++-
 .../tests/unit/common/ai/operators/test_agent.py   | 45 ++++++++++++++--------
 6 files changed, 59 insertions(+), 19 deletions(-)

diff --git a/providers/common/ai/docs/message_history.rst 
b/providers/common/ai/docs/message_history.rst
index 453b4d7a7c3..fa9ee78befe 100644
--- a/providers/common/ai/docs/message_history.rst
+++ b/providers/common/ai/docs/message_history.rst
@@ -30,6 +30,13 @@ turns and, after the run, pushes the full updated transcript
 run reads it back to resume the conversation. ``None`` (the default) keeps the
 single-turn behavior unchanged.
 
+To record the transcript of a single-turn run, set 
``emit_message_history=True``.
+The operator starts the run with no prior turns and pushes 
``result.all_messages()``
+to the same ``message_history`` XCom key, so the transcript is available to
+downstream tasks for inspection, evaluation or storage. 
``emit_message_history`` defaults
+to ``True`` when ``message_history`` is set; pass ``False`` to keep a seeded 
run from
+pushing it.
+
 The operator does **not** decide *where* a session is stored -- that keying is
 deployment-specific. The pattern is three tasks: load the prior transcript for
 the session, run the agent, store the updated transcript. The example keys a
@@ -49,7 +56,8 @@ fresh session instead of trying to parse the string 
``"None"``.
 
 .. note::
 
-    ``message_history`` cannot be combined with ``enable_hitl_review`` -- the
+    ``message_history`` and ``emit_message_history`` cannot be combined with
+    ``enable_hitl_review`` -- the
     operator raises at construction. The post-review (human-approved) 
transcript
     is not recoverable today, so emitting the pre-review transcript would
     silently drop the reviewed turns.
diff --git a/providers/common/ai/docs/operators/agent.rst 
b/providers/common/ai/docs/operators/agent.rst
index fbb7b542989..9dbf792c9c9 100644
--- a/providers/common/ai/docs/operators/agent.rst
+++ b/providers/common/ai/docs/operators/agent.rst
@@ -451,6 +451,9 @@ Parameters
   When set, the post-run transcript is pushed to XCom under the key
   ``message_history`` for the next run to resume. Default ``None`` 
(single-turn).
   See :doc:`../message_history`.
+- ``emit_message_history``: Push the post-run transcript to XCom under the key
+  ``message_history``. Defaults to ``True`` when ``message_history`` is set 
and ``False``
+  otherwise; ``True`` records a single-turn run without seeding it. See 
:doc:`../message_history`.
 - ``serialize_output``: If ``True`` and ``output_type`` is a Pydantic
   ``BaseModel`` subclass, the model instance is dumped to a ``dict`` via
   ``model_dump()`` before being pushed to XCom. Default ``False`` -- the
diff --git a/providers/common/ai/docs/stability.rst 
b/providers/common/ai/docs/stability.rst
index 5feb431be94..3fad7b4ca4f 100644
--- a/providers/common/ai/docs/stability.rst
+++ b/providers/common/ai/docs/stability.rst
@@ -69,6 +69,10 @@ Pydantic AI toolsets, but the Pydantic AI class they inherit 
from can change.
      - Seeds the run with the given conversation, as a list of messages or its 
JSON form,
        and publishes the finished conversation under the ``message_history`` 
XCom key so a
        later run can continue it. Cannot be combined with 
``enable_hitl_review``.
+   * - ``emit_message_history`` on ``AgentOperator``
+     - Publishes the finished conversation under the ``message_history`` XCom 
key without
+       seeding the run. Defaults to ``True`` when ``message_history`` is set, 
and ``False``
+       stops a seeded run from publishing it. Cannot be combined with 
``enable_hitl_review``.
    * - ``durable=True`` on ``AgentOperator`` and ``@task.agent``, and
        :class:`~airflow.providers.common.ai.durable.AirflowDurability`
        (:doc:`durable_execution`)
diff --git a/providers/common/ai/docs/troubleshooting.rst 
b/providers/common/ai/docs/troubleshooting.rst
index ea3e8c3fae5..e2b329e74dc 100644
--- a/providers/common/ai/docs/troubleshooting.rst
+++ b/providers/common/ai/docs/troubleshooting.rst
@@ -130,7 +130,7 @@ as a task failure.
     ``AgentOperator(durable=True)`` adds the capability to the agent, so a 
second one in
     ``capabilities`` would record every step twice. Drop one of them.
 
-``message_history and enable_hitl_review=True cannot be used together``
+``message_history and enable_hitl_review=True cannot be used together`` and 
``emit_message_history and enable_hitl_review=True cannot be used together``
     The post-review transcript is not recoverable today, so the operator 
refuses rather
     than silently dropping the reviewed turns. See :doc:`message_history`.
 
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 06f052cb6c2..2392a1fdd19 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
@@ -406,6 +406,11 @@ class AgentOperator(CancellableAgentRunMixin, 
BaseOperator, HITLReviewMixin, Usa
         object-storage XCom backend or trim old turns. Not supported together 
with
         ``enable_hitl_review`` (raises) -- the post-review transcript is not 
yet
         recoverable.
+    :param emit_message_history: Push the post-run transcript to XCom under 
the key
+        ``message_history``. Defaults to ``True`` when ``message_history`` is 
set and ``False``
+        otherwise. Set ``True`` to record a single-turn run without seeding 
it, or ``False``
+        to keep a seeded run from pushing it. Not supported together with
+        ``enable_hitl_review`` (raises).
 
     **HITL Review parameters** (requires the ``hitl_review`` plugin):
 
@@ -507,6 +512,7 @@ class AgentOperator(CancellableAgentRunMixin, BaseOperator, 
HITLReviewMixin, Usa
         durable: bool = False,
         cache_prompt: bool = True,
         message_history: list[ModelMessage] | str | bytes | None = None,
+        emit_message_history: bool | None = None,
         # Agent feedback parameters
         enable_hitl_review: bool = False,
         max_hitl_iterations: int = 5,
@@ -537,6 +543,9 @@ class AgentOperator(CancellableAgentRunMixin, BaseOperator, 
HITLReviewMixin, Usa
         # No validation here -- see coerce_usage_limits() docstring for why.
         self.usage_limits = usage_limits
         self.message_history = message_history
+        self.emit_message_history = (
+            message_history is not None if emit_message_history is None else 
emit_message_history
+        )
 
         self.durable = durable
         self.cache_prompt = cache_prompt
@@ -581,6 +590,9 @@ class AgentOperator(CancellableAgentRunMixin, BaseOperator, 
HITLReviewMixin, Usa
             # surface the final message history.
             raise ValueError("message_history and enable_hitl_review=True 
cannot be used together.")
 
+        if emit_message_history and enable_hitl_review:
+            raise ValueError("emit_message_history and enable_hitl_review=True 
cannot be used together.")
+
         if durable or enable_hitl_review:
             self._reject_sandbox_without_continuity(durable=durable, 
enable_hitl_review=enable_hitl_review)
 
@@ -1078,7 +1090,7 @@ class AgentOperator(CancellableAgentRunMixin, 
BaseOperator, HITLReviewMixin, Usa
         if self._usage_budget and (run_usage := self._run_usage) is not None:
             self._log_cumulative_usage(run_usage)
 
-        if self.message_history is not None:
+        if self.emit_message_history:
             self._emit_message_history(context, result)
 
         output = result.output
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 70d7faa9e41..178db398166 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
@@ -2127,22 +2127,32 @@ class TestAgentOperatorMessageHistory:
         assert "message_history" not in pushed_keys
         assert pushed_keys == {"run_id", "usage", MODEL_NAME_XCOM_KEY}
 
+    @pytest.mark.parametrize(
+        ("kwargs", "emits"),
+        [
+            pytest.param({"message_history": []}, True, id="seeded"),
+            pytest.param({"emit_message_history": True}, True, id="opt-in"),
+            pytest.param({"message_history": [], "emit_message_history": 
False}, False, id="opt-out"),
+        ],
+    )
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
-    def test_transcript_emitted_to_xcom_when_history_set(self, mock_hook_cls, 
make_mock_run_result):
-        """When message_history is set, the post-run transcript is pushed to 
XCom and round-trips."""
+    def test_transcript_emitted_to_xcom(self, mock_hook_cls, kwargs, emits, 
make_mock_run_result):
+        """The transcript push follows emit_message_history, which defaults to 
whether the run is seeded."""
         mock_agent = _make_mock_agent("ok", make_mock_run_result)
         mock_agent.run_sync.return_value.all_messages.return_value = 
_sample_history()
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
-        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c", 
message_history=[])
+        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c", 
**kwargs)
         context = _make_context()
         op.execute(context=context)
 
+        # emit_message_history never seeds the run.
+        assert ("message_history" in mock_agent.run_sync.call_args.kwargs) is 
("message_history" in kwargs)
         ti = context["task_instance"]
         pushes = {c.kwargs["key"]: c.kwargs["value"] for c in 
ti.xcom_push.call_args_list}
-        assert set(pushes) == {"run_id", "usage", "message_history", 
MODEL_NAME_XCOM_KEY}
-        restored = 
ModelMessagesTypeAdapter.validate_json(pushes["message_history"])
-        assert len(restored) == 2
+        assert ("message_history" in pushes) is emits
+        if emits:
+            assert 
len(ModelMessagesTypeAdapter.validate_json(pushes["message_history"])) == 2
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_usage_limits_still_forwarded_with_history(
@@ -2165,16 +2175,19 @@ class TestAgentOperatorMessageHistory:
     @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)."""
-        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,
-            )
+    @pytest.mark.parametrize(
+        ("kwargs", "match"),
+        [
+            pytest.param({"message_history": []}, "message_history and 
enable_hitl_review", id="seeded"),
+            pytest.param(
+                {"emit_message_history": True}, "emit_message_history and 
enable_hitl_review", id="opt-in"
+            ),
+        ],
+    )
+    def test_message_history_with_hitl_review_raises(self, kwargs, match):
+        """Transcript emission cannot be combined with HITL review 
(post-review transcript is lost)."""
+        with pytest.raises(ValueError, match=match):
+            AgentOperator(task_id="t", prompt="run", llm_conn_id="c", 
enable_hitl_review=True, **kwargs)
 
     def test_durable_path_also_seeds_message_history(self):
         """The durable branch forwards message_history into the run, and keeps 
its conversation id."""

Reply via email to