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

pankajkoti 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 833b61fadc1 Stamp Airflow run identity onto agent runs and traces 
(#73275)
833b61fadc1 is described below

commit 833b61fadc1ff4ec9b3a915bccbd4d35c7e3df2e
Author: Pankaj Koti <[email protected]>
AuthorDate: Thu Sep 17 19:59:04 2026 +0530

    Stamp Airflow run identity onto agent runs and traces (#73275)
    
    * Stamp Airflow run identity onto agent runs and traces
    
    An agent run and the task instance that produced it shared no identifier,
    so a GenAI trace in an OTel backend could not be tied back to its task and
    a task had no handle on its own run. Observability surfaces had to
    reconstruct that join from timestamps. Giving the two a shared, per-attempt
    key makes the join exact and unblocks a downstream AI traces UI.
    
    * Address review feedback on agent run identity stamping
    
    The first pass mutated a possibly caller-owned InstrumentationSettings 
object
    in place, so a settings instance shared across agent tasks or reused across 
HITL
    re-runs would carry the wrong run's identity and accumulate nested tracer
    wrappers. It also wrote run metadata to XCom regardless of do_xcom_push, 
guarded
    a task-instance id that is non-nullable at runtime, and omitted the run 
cost the
    docstring already promised. These changes keep the stamping per-run, honour 
the
    operator's XCom suppression flag, drop the dead guard, and surface cost.
---
 providers/common/ai/docs/observability.rst         |  28 +++-
 .../airflow/providers/common/ai/observability.py   |  85 ++++++++++-
 .../airflow/providers/common/ai/operators/agent.py |  52 ++++++-
 .../tests/unit/common/ai/decorators/test_agent.py  |  42 ++++--
 .../tests/unit/common/ai/operators/test_agent.py   | 156 +++++++++++++++++----
 .../ai/tests/unit/common/ai/test_observability.py  |  88 ++++++++++++
 6 files changed, 401 insertions(+), 50 deletions(-)

diff --git a/providers/common/ai/docs/observability.rst 
b/providers/common/ai/docs/observability.rst
index 5715b58be5b..918c6f7ffd3 100644
--- a/providers/common/ai/docs/observability.rst
+++ b/providers/common/ai/docs/observability.rst
@@ -41,13 +41,35 @@ How it works
   configured under ``[traces]`` / the standard ``OTEL_EXPORTER_OTLP_*``
   environment variables. If core tracing is not enabled in the worker process,
   no GenAI spans are emitted.
-* **Correlation is automatic.** The worker opens a task span before the 
operator
-  runs, so the agent's spans nest under it and inherit the task's ``trace_id``
-  and ``airflow.*`` attributes (dag id, run id, task id, try number, map 
index).
+* **Correlation.** The worker opens a task span before the operator runs, so 
the
+  agent's spans nest under it and share its ``trace_id``.
+  :class:`~airflow.providers.common.ai.operators.agent.AgentOperator` (and
+  ``@task.agent``) additionally stamps the task-instance identity on every 
GenAI
+  span it emits: the five keys core tracing already puts on the task span
+  (``airflow.dag_id``, ``airflow.task_id``, ``airflow.dag_run.run_id``,
+  ``airflow.task_instance.try_number``, ``airflow.task_instance.map_index``) 
plus
+  ``airflow.task_instance.id`` as the per-attempt run join key. So a span is
+  filterable by dag, task, run, attempt, or map index directly, without walking
+  up to the parent span (OpenTelemetry children inherit trace context, not
+  attributes).
   An automatic retry reuses the task instance's persisted trace context, so all
   attempts share one trace and appear as repeated task-run spans on it,
   distinguished by ``try number``. Only a manual clear or rerun regenerates the
   context and starts a new trace.
+* **Run join key.** For an ``AgentOperator`` run, the task-instance id (unique
+  per attempt, since Airflow regenerates it on each retry) is passed to
+  pydantic-ai as the run's ``run_id``. It surfaces on the run's GenAI spans as
+  ``gen_ai.agent.call.id``, and the operator also exposes it, alongside the 
run's
+  token usage, on XCom under the ``run_id`` and ``usage`` keys. A downstream 
task
+  can then reference the run
+  (``ti.xcom_pull(task_ids="my_agent", key="run_id")``) and a trace backend can
+  join a task's output to its agent trace without parsing logs. With
+  ``enable_hitl_review`` the ``run_id`` and ``usage`` reflect the initial model
+  run, not the human-feedback regenerations.
+* **Scope.** The ``airflow.*`` identity attributes and the ``run_id`` / 
``usage``
+  XComs come only from ``AgentOperator`` and ``@task.agent``. The other LLM
+  operators still emit GenAI spans correlated to the task span by nesting, but
+  without the identity attributes or the run join key.
 * **Content is off by default.** Only token counts, model id, latency, tool
   names, and finish reason are recorded. Prompt and completion text is never
   emitted unless you opt in (see below).
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/observability.py 
b/providers/common/ai/src/airflow/providers/common/ai/observability.py
index 08a59c40595..25e4a8712b7 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/observability.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/observability.py
@@ -31,11 +31,17 @@ or not configured in this process) no spans are emitted.
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Literal
+import copy
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, Literal, cast
 
 from airflow.providers.common.compat.sdk import conf
 
 if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from opentelemetry.trace import Span, Tracer
+    from pydantic_ai import Agent
     from pydantic_ai.models.instrumented import InstrumentationSettings
 
 SECTION = "common.ai"
@@ -106,3 +112,80 @@ def genai_instrumentation_settings() -> 
InstrumentationSettings | None:
         include_binary_content=False,
         tracer_provider=provider,
     )
+
+
+def build_run_identity_attributes(ti: Any) -> dict[str, Any]:
+    """
+    Build the Airflow identity attributes to stamp on a run's GenAI spans.
+
+    Reuses core's task-span attribute keys (see ``_make_task_span``) so agent
+    spans filter identically to the task span they nest under, plus the
+    per-attempt task-instance id as the run join key carried on every span.
+    """
+    return {
+        "airflow.dag_id": ti.dag_id,
+        "airflow.task_id": ti.task_id,
+        "airflow.dag_run.run_id": ti.run_id,
+        "airflow.task_instance.try_number": ti.try_number,
+        "airflow.task_instance.map_index": ti.map_index if ti.map_index is not 
None else -1,
+        "airflow.task_instance.id": str(ti.id),
+    }
+
+
+def stamp_identity_on_agent_spans(agent: Agent, attributes: dict[str, Any]) -> 
None:
+    """
+    Stamp *attributes* on every GenAI span *agent* emits during its run.
+
+    pydantic-ai opens all of a run's agent/model/tool spans from
+    ``InstrumentationSettings.tracer``, so wrapping that one tracer reaches 
them
+    all without touching the shared core ``TracerProvider``. No-op when the
+    agent is not instrumented with an ``InstrumentationSettings`` (tracing off,
+    or the caller supplied its own non-settings ``instrument`` value).
+
+    The settings object may be caller-owned and shared across agents (a single
+    module-level ``InstrumentationSettings`` handed to several tasks) or reused
+    across HITL re-runs. Mutating it in place would leak one run's identity 
into
+    another and nest ``_IdentityTracer`` wrappers on each stamp, so we wrap a 
copy
+    and swap it onto this agent, leaving the original untouched.
+    """
+    from pydantic_ai.models.instrumented import InstrumentationSettings
+
+    instrument = agent.instrument
+    if isinstance(instrument, InstrumentationSettings):
+        # ``tracer`` is not a constructor arg, so copy then set the attribute.
+        settings = copy.copy(instrument)
+        # _IdentityTracer implements the Tracer surface structurally (it cannot
+        # subclass Tracer without importing opentelemetry at module load).
+        settings.tracer = cast("Tracer", _IdentityTracer(instrument.tracer, 
attributes))
+        agent.instrument = settings
+
+
+class _IdentityTracer:
+    """OpenTelemetry ``Tracer`` wrapper that stamps fixed attributes on every 
span it starts."""
+
+    def __init__(self, tracer: Tracer, attributes: dict[str, Any]) -> None:
+        self._tracer = tracer
+        self._attributes = attributes
+
+    def start_span(self, *args: Any, **kwargs: Any) -> Span:
+        span = self._tracer.start_span(*args, **kwargs)
+        span.set_attributes(self._attributes)
+        return span
+
+    @contextmanager
+    def start_as_current_span(self, *args: Any, **kwargs: Any) -> 
Iterator[Span]:
+        with self._tracer.start_as_current_span(*args, **kwargs) as span:
+            span.set_attributes(self._attributes)
+            yield span
+
+    def __getattr__(self, name: str) -> Any:
+        # Delegate any other Tracer method to the wrapped tracer. Dunders are 
not
+        # forwarded (so copy/pickle of the settings does not silently unwrap 
the
+        # stamping), and the ``__dict__`` lookup guards against recursion 
before
+        # ``_tracer`` is set.
+        if name.startswith("__"):
+            raise AttributeError(name)
+        tracer = self.__dict__.get("_tracer")
+        if tracer is None:
+            raise AttributeError(name)
+        return getattr(tracer, name)
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 612b7027d3b..e5d1212dad5 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
@@ -29,6 +29,10 @@ from pydantic import BaseModel
 
 from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
 from airflow.providers.common.ai.mixins.hitl_review import HITLReviewMixin
+from airflow.providers.common.ai.observability import (
+    build_run_identity_attributes,
+    stamp_identity_on_agent_spans,
+)
 from airflow.providers.common.ai.utils.logging import log_run_summary, 
wrap_toolsets_for_logging
 from airflow.providers.common.ai.utils.output_type import 
rehydrate_pydantic_output
 from airflow.providers.common.ai.utils.usage import coerce_usage_limits
@@ -126,6 +130,13 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
     and run the agent. The agent reasons about the prompt, calls tools in a
     multi-turn loop, and returns a final answer.
 
+    Alongside the returned agent output, the run's ``run_id`` and token 
``usage``
+    are pushed to XCom under the ``run_id`` and ``usage`` keys, so a downstream
+    task can reference the run and its cost. The ``run_id`` also ties the task 
to
+    its GenAI trace (see the provider's observability docs). With
+    ``enable_hitl_review``, these reflect the initial model run, not the
+    human-feedback regenerations.
+
     :param prompt: The prompt to send to the agent.
     :param llm_conn_id: Connection ID for the LLM provider.
     :param model_id: Model identifier (e.g. ``"openai:gpt-5"``).
@@ -470,7 +481,14 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
 
         agent = self._build_agent()
 
-        run_kwargs: dict[str, Any] = {"usage_limits": usage_limits}
+        ti = context["task_instance"]
+        self._run_identity_attrs = build_run_identity_attributes(ti)
+        stamp_identity_on_agent_spans(agent, self._run_identity_attrs)
+
+        # The task-instance id is non-nullable and regenerated on each retry, 
so it
+        # is a unique, reverse-resolvable join key. It lands on result.run_id, 
the
+        # run's messages, and the ``gen_ai.agent.call.id`` span attribute.
+        run_kwargs: dict[str, Any] = {"usage_limits": usage_limits, "run_id": 
str(ti.id)}
         history = self._resolve_message_history()
         if history is not None:
             run_kwargs["message_history"] = history
@@ -492,6 +510,7 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
             result = agent.run_sync(self.prompt, **run_kwargs)
 
         log_run_summary(self.log, result)
+        self._emit_run_metadata(context, result)
 
         if self._durable_counter is not None:
             c = self._durable_counter
@@ -535,10 +554,10 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
             output = output.model_dump()
 
         # Clean up the durable cache only after the run and every post-run step
-        # that can still fail (the message-history XCom push above and output
-        # serialization) has succeeded. Cleaning up earlier and then raising
-        # would leave the Airflow retry with an empty cache, re-executing every
-        # already-completed model and tool step.
+        # that can still fail (the run-metadata and message-history XCom pushes
+        # above and output serialization) has succeeded. Cleaning up earlier 
and
+        # then raising would leave the Airflow retry with an empty cache,
+        # re-executing every already-completed model and tool step.
         if self._durable_storage is not None:
             self._durable_storage.cleanup()
         return output
@@ -573,10 +592,33 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
         transcript = 
ModelMessagesTypeAdapter.dump_json(result.all_messages()).decode()
         context["task_instance"].xcom_push(key="message_history", 
value=transcript)
 
+    def _emit_run_metadata(self, context: Context, result: Any) -> None:
+        """Expose the pydantic-ai run id and token usage on XCom for 
downstream tasks."""
+        if not self.do_xcom_push:
+            return
+        usage = result.usage
+        ti = context["task_instance"]
+        ti.xcom_push(key="run_id", value=result.run_id)
+        ti.xcom_push(
+            key="usage",
+            value={
+                "requests": usage.requests,
+                "input_tokens": usage.input_tokens,
+                "output_tokens": usage.output_tokens,
+                "total_tokens": usage.total_tokens,
+                "tool_calls": usage.tool_calls,
+                # Decimal | None, stringified so XCom serialization stays 
lossless.
+                "cost": str(usage.cost) if usage.cost is not None else None,
+            },
+        )
+
     def regenerate_with_feedback(self, *, feedback: str, message_history: Any) 
-> tuple[str, Any]:
         """Re-run the agent with *feedback* appended to the conversation 
history."""
         usage_limits = coerce_usage_limits(self.usage_limits)
         agent = self._build_agent()
+        identity = getattr(self, "_run_identity_attrs", None)
+        if identity:
+            stamp_identity_on_agent_spans(agent, identity)
         messages = message_history or []
         result = agent.run_sync(
             feedback,
diff --git a/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py 
b/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
index 1731805fe11..00fd431b66f 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
@@ -40,6 +40,16 @@ class Summary(BaseModel):
     text: str
 
 
+def _make_context():
+    """A context whose ``task_instance`` carries a stable id for run-identity 
stamping.
+
+    A real dict (not a mock) so the decorator's ``context_merge`` of 
``op_kwargs`` works.
+    """
+    ti = MagicMock()
+    ti.configure_mock(id="ti-1", dag_id="dag", task_id="task", run_id="run", 
map_index=-1, try_number=1)
+    return {"task_instance": ti}
+
+
 class TestAgentDecoratedOperator:
     def test_custom_operator_name(self):
         assert _AgentDecoratedOperator.custom_operator_name == "@task.agent"
@@ -47,7 +57,7 @@ class TestAgentDecoratedOperator:
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_calls_callable_and_returns_output(self, mock_hook_cls, 
make_mock_run_result):
         """The callable's return value becomes the agent prompt."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("The top 
customer is Acme Corp.")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -55,11 +65,13 @@ class TestAgentDecoratedOperator:
             return "Who is our top customer?"
 
         op = _AgentDecoratedOperator(task_id="test", 
python_callable=my_prompt, llm_conn_id="my_llm")
-        result = op.execute(context={})
+        result = op.execute(context=_make_context())
 
         assert result == "The top customer is Acme Corp."
         assert op.prompt == "Who is our top customer?"
-        mock_agent.run_sync.assert_called_once_with("Who is our top 
customer?", usage_limits=None)
+        mock_agent.run_sync.assert_called_once_with(
+            "Who is our top customer?", usage_limits=None, run_id="ti-1"
+        )
 
     @pytest.mark.parametrize(
         "return_value",
@@ -79,7 +91,7 @@ class TestAgentDecoratedOperator:
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_accepts_sequence_prompt(self, mock_hook_cls, 
make_mock_run_result):
         """A non-empty Sequence[UserContent] return value is forwarded to 
run_sync as-is."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("ok")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -90,10 +102,10 @@ class TestAgentDecoratedOperator:
             return prompt
 
         op = _AgentDecoratedOperator(task_id="test", 
python_callable=my_prompt, llm_conn_id="my_llm")
-        op.execute(context={})
+        op.execute(context=_make_context())
 
         assert op.prompt == prompt
-        mock_agent.run_sync.assert_called_once_with(prompt, usage_limits=None)
+        mock_agent.run_sync.assert_called_once_with(prompt, usage_limits=None, 
run_id="ti-1")
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_sequence_prompt_with_hitl_review_raises_before_run_sync(self, 
mock_hook_cls):
@@ -103,7 +115,7 @@ class TestAgentDecoratedOperator:
         if not AIRFLOW_V_3_1_PLUS:
             pytest.skip("enable_hitl_review requires Airflow >= 3.1.0")
 
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
         op = _AgentDecoratedOperator(
@@ -120,7 +132,7 @@ class TestAgentDecoratedOperator:
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_merges_op_kwargs_into_callable(self, mock_hook_cls, 
make_mock_run_result):
         """op_kwargs are resolved by the callable to build the prompt."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("done")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -133,15 +145,17 @@ class TestAgentDecoratedOperator:
             llm_conn_id="my_llm",
             op_kwargs={"topic": "revenue trends"},
         )
-        op.execute(context={"task_instance": MagicMock()})
+        op.execute(context=_make_context())
 
         assert op.prompt == "Analyze revenue trends"
-        mock_agent.run_sync.assert_called_once_with("Analyze revenue trends", 
usage_limits=None)
+        mock_agent.run_sync.assert_called_once_with(
+            "Analyze revenue trends", usage_limits=None, run_id="ti-1"
+        )
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_passes_toolsets_through(self, mock_hook_cls, 
make_mock_run_result):
         """Toolsets passed to the decorator are forwarded to the agent."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("result")
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -153,7 +167,7 @@ class TestAgentDecoratedOperator:
             llm_conn_id="my_llm",
             toolsets=[mock_toolset],
         )
-        op.execute(context={})
+        op.execute(context=_make_context())
 
         create_call = 
mock_hook_cls.get_hook.return_value.create_agent.call_args
         passed_toolsets = create_call[1]["toolsets"]
@@ -165,7 +179,7 @@ class TestAgentDecoratedOperator:
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_structured_output(self, mock_hook_cls, 
make_mock_run_result):
         """BaseModel output flows through XCom as the Pydantic instance."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = 
make_mock_run_result(Summary(text="Great results"))
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -175,7 +189,7 @@ class TestAgentDecoratedOperator:
             llm_conn_id="my_llm",
             output_type=Summary,
         )
-        result = op.execute(context={})
+        result = op.execute(context=_make_context())
 
         assert isinstance(result, Summary)
         assert result.text == "Great results"
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 462f5fc70c9..da8360f24c5 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
@@ -66,13 +66,30 @@ class Summary(BaseModel):
     score: float = 0.0
 
 
-def _make_mock_agent(output, make_mock_run_result):
+def _make_mock_agent(output, make_mock_run_result, *, cost=None):
     """Create a mock agent that returns the given output."""
-    mock_agent = MagicMock(spec=["run_sync"])
-    mock_agent.run_sync.return_value = make_mock_run_result(output)
+    mock_agent = MagicMock(spec=["run_sync", "instrument"])
+    mock_agent.run_sync.return_value = make_mock_run_result(output, cost=cost)
     return mock_agent
 
 
+def _make_ti(*, id="ti-1", dag_id="dag", task_id="task", run_id="run", 
map_index=-1, try_number=1):
+    """Return a task-instance double carrying the identity fields execute() 
reads."""
+    ti = MagicMock()
+    ti.configure_mock(
+        id=id, dag_id=dag_id, task_id=task_id, run_id=run_id, 
map_index=map_index, try_number=try_number
+    )
+    return ti
+
+
+def _make_context(ti=None):
+    """A context whose ``task_instance`` is a configured ti. Other keys stay 
generic mocks."""
+    ti = ti if ti is not None else _make_ti()
+    ctx = MagicMock()
+    ctx.__getitem__.side_effect = lambda key: ti if key == "task_instance" 
else MagicMock()
+    return ctx
+
+
 PRICED_COST = Decimal("0.10")
 
 
@@ -201,9 +218,9 @@ class TestAgentOperatorExecute:
             llm_conn_id="my_llm",
             usage_limits=limits,
         )
-        op.execute(context=MagicMock())
+        op.execute(context=_make_context())
 
-        mock_agent.run_sync.assert_called_once_with("run", usage_limits=limits)
+        mock_agent.run_sync.assert_called_once_with("run", 
usage_limits=limits, run_id="ti-1")
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_coerces_usage_limits_dict_before_run_sync(self, 
mock_hook_cls, make_mock_run_result):
@@ -301,7 +318,7 @@ class TestAgentOperatorExecute:
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_raises_valueerror_for_unparsable_usage_limits_value(self, 
mock_hook_cls):
         """An unparsable templated ``usage_limits`` value surfaces as 
``ValueError`` before any model call."""
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
         op = AgentOperator(
@@ -360,14 +377,14 @@ class TestAgentOperatorExecute:
             llm_conn_id="my_llm",
             system_prompt="You are helpful.",
         )
-        result = op.execute(context=MagicMock())
+        result = op.execute(context=_make_context())
 
         assert result == "The answer is 42."
         mock_hook_cls.get_hook.assert_called_once_with("my_llm", 
hook_params={"model_id": None})
         
mock_hook_cls.get_hook.return_value.create_agent.assert_called_once_with(
             output_type=str, instructions="You are helpful."
         )
-        mock_agent.run_sync.assert_called_once_with("What is the answer?", 
usage_limits=None)
+        mock_agent.run_sync.assert_called_once_with("What is the answer?", 
usage_limits=None, run_id="ti-1")
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_passes_toolsets_in_agent_kwargs(self, mock_hook_cls, 
make_mock_run_result):
@@ -567,7 +584,7 @@ class TestAgentOperatorExecute:
         msg_history = [MagicMock()]
         mock_result = make_mock_run_result("Initial output")
         mock_result.all_messages.return_value = msg_history
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_run_hitl.return_value = "Approved output"
@@ -596,7 +613,7 @@ class TestAgentOperatorExecute:
     ):
         """When enable_hitl_review=True and output_type is BaseModel, execute 
returns the model instance."""
         mock_result = make_mock_run_result(Summary(text="Approved summary", 
score=0.9))
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         # run_hitl_review returns JSON string (as stored in 
session.current_output)
@@ -627,7 +644,7 @@ class TestAgentOperatorExecute:
     ):
         """When enable_hitl_review=True and output_type is str, execute 
returns string as-is."""
         mock_result = make_mock_run_result("Initial output")
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_run_hitl.return_value = "Approved output"
@@ -657,7 +674,7 @@ class TestAgentOperatorExecute:
         from airflow.providers.common.ai.exceptions import 
HITLMaxIterationsError
 
         mock_result = make_mock_run_result("Initial output")
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
         mock_run_hitl.side_effect = HITLMaxIterationsError("Task exceeded max 
iterations.")
@@ -731,7 +748,7 @@ class TestAgentOperatorRegenerateWithFeedback:
         msg_history = [MagicMock()]
         mock_result = make_mock_run_result("Revised output")
         mock_result.all_messages.return_value = msg_history + [MagicMock()]
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -758,7 +775,7 @@ class TestAgentOperatorRegenerateWithFeedback:
         """regenerate_with_feedback returns JSON string for BaseModel 
output."""
         mock_result = make_mock_run_result(Summary(text="Revised"))
         mock_result.all_messages.return_value = []
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_agent.run_sync.return_value = mock_result
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
@@ -854,10 +871,10 @@ class TestAgentOperatorDurable:
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
         op = AgentOperator(task_id="test", prompt="test", llm_conn_id="my_llm")
-        op.execute(context=MagicMock())
+        op.execute(context=_make_context())
 
         # run_sync called directly, no override
-        mock_agent.run_sync.assert_called_once_with("test", usage_limits=None)
+        mock_agent.run_sync.assert_called_once_with("test", usage_limits=None, 
run_id="ti-1")
 
     def test_build_durable_capabilities_wraps_toolset_capability(self):
         """A ``Toolset`` capability's inner toolset is wrapped with 
CachingToolset;
@@ -940,7 +957,7 @@ class TestAgentOperatorDurable:
         storage = MagicMock(spec=DurableStorageProtocol)
         mock_build_storage.return_value = storage
 
-        mock_agent = MagicMock(spec=["run_sync", "model", "override"])
+        mock_agent = MagicMock(spec=["run_sync", "model", "override", 
"instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("ok")
         mock_agent.model = "test-model"
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
@@ -948,7 +965,7 @@ class TestAgentOperatorDurable:
         op = AgentOperator(task_id="t", prompt="p", llm_conn_id="c", 
durable=True, message_history="[]")
         with patch.object(op, "_emit_message_history", 
side_effect=RuntimeError("xcom down")):
             with pytest.raises(RuntimeError, match="xcom down"):
-                op.execute(context={})
+                op.execute(context=_make_context())
 
         storage.cleanup.assert_not_called()
 
@@ -966,7 +983,7 @@ class TestAgentOperatorMultimodalPromptGuard:
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
     def test_execute_rejects_sequence_prompt_with_hitl_review(self, 
mock_hook_cls):
-        mock_agent = MagicMock(spec=["run_sync"])
+        mock_agent = MagicMock(spec=["run_sync", "instrument"])
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
         op = AgentOperator(
@@ -1030,11 +1047,14 @@ class TestAgentOperatorMessageHistory:
         mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
 
         op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c")
-        context = MagicMock()
+        context = _make_context()
         op.execute(context=context)
 
         assert "message_history" not in mock_agent.run_sync.call_args.kwargs
-        context["task_instance"].xcom_push.assert_not_called()
+        # The transcript is not emitted without history, but run id + usage 
always are.
+        pushed_keys = {c.kwargs["key"] for c in 
context["task_instance"].xcom_push.call_args_list}
+        assert "message_history" not in pushed_keys
+        assert pushed_keys == {"run_id", "usage"}
 
     @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):
@@ -1044,14 +1064,13 @@ class TestAgentOperatorMessageHistory:
         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=[])
-        context = MagicMock()
+        context = _make_context()
         op.execute(context=context)
 
         ti = context["task_instance"]
-        ti.xcom_push.assert_called_once()
-        push_kwargs = ti.xcom_push.call_args.kwargs
-        assert push_kwargs["key"] == "message_history"
-        restored = ModelMessagesTypeAdapter.validate_json(push_kwargs["value"])
+        pushes = {c.kwargs["key"]: c.kwargs["value"] for c in 
ti.xcom_push.call_args_list}
+        assert set(pushes) == {"run_id", "usage", "message_history"}
+        restored = 
ModelMessagesTypeAdapter.validate_json(pushes["message_history"])
         assert len(restored) == 2
 
     @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
@@ -1096,7 +1115,7 @@ class TestAgentOperatorMessageHistory:
 
         mock_build_storage.return_value = 
MagicMock(spec=DurableStorageProtocol)
 
-        mock_agent = MagicMock(spec=["run_sync", "model", "override"])
+        mock_agent = MagicMock(spec=["run_sync", "model", "override", 
"instrument"])
         mock_agent.run_sync.return_value = make_mock_run_result("ok")
         mock_agent.model = "test-model"
         mock_agent.override.return_value.__enter__ = 
MagicMock(return_value=None)
@@ -1148,3 +1167,86 @@ class TestAgentOperatorHITLArgumentChecks:
                 enable_hitl_review=True,
                 **conflicting_kwargs,
             )
+
+
+class TestAgentOperatorRunIdentity:
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_run_id_and_usage_pushed_to_xcom(self, mock_hook_cls, 
make_mock_run_result):
+        """The pydantic-ai run id and token usage are exposed on XCom for 
downstream tasks."""
+        mock_agent = _make_mock_agent("ok", make_mock_run_result)
+        mock_agent.run_sync.return_value.run_id = "the-run-id"
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+
+        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c")
+        context = _make_context()
+        op.execute(context=context)
+
+        pushes = {
+            c.kwargs["key"]: c.kwargs["value"] for c in 
context["task_instance"].xcom_push.call_args_list
+        }
+        assert pushes["run_id"] == "the-run-id"
+        assert pushes["usage"] == {
+            "requests": 1,
+            "input_tokens": 0,
+            "output_tokens": 0,
+            "total_tokens": 0,
+            "tool_calls": 0,
+            "cost": None,
+        }
+
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_usage_cost_is_stringified_on_xcom(self, mock_hook_cls, 
make_mock_run_result):
+        """A non-None run cost (Decimal) is stringified before it goes to 
XCom."""
+        mock_agent = _make_mock_agent("ok", make_mock_run_result, 
cost=PRICED_COST)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+
+        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c")
+        context = _make_context()
+        op.execute(context=context)
+
+        pushes = {
+            c.kwargs["key"]: c.kwargs["value"] for c in 
context["task_instance"].xcom_push.call_args_list
+        }
+        assert pushes["usage"]["cost"] == str(PRICED_COST)
+
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_run_metadata_not_pushed_when_do_xcom_push_false(self, 
mock_hook_cls, make_mock_run_result):
+        """do_xcom_push=False suppresses the run_id/usage pushes like any 
other operator XCom."""
+        mock_agent = _make_mock_agent("ok", make_mock_run_result)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+
+        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c", 
do_xcom_push=False)
+        context = _make_context()
+        op.execute(context=context)
+
+        pushed_keys = {c.kwargs["key"] for c in 
context["task_instance"].xcom_push.call_args_list}
+        assert "run_id" not in pushed_keys
+        assert "usage" not in pushed_keys
+
+    
@patch("airflow.providers.common.ai.operators.agent.stamp_identity_on_agent_spans",
 autospec=True)
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_stamps_identity_on_agent_spans(self, mock_hook_cls, 
mock_stamp, make_mock_run_result):
+        """execute() derives the identity from the task instance and stamps it 
on the agent's spans."""
+        mock_agent = _make_mock_agent("ok", make_mock_run_result)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+
+        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c")
+        op.execute(context=_make_context(_make_ti(id="ti-9")))
+
+        mock_stamp.assert_called_once()
+        agent_arg, attrs = mock_stamp.call_args.args
+        assert agent_arg is mock_agent
+        assert attrs["airflow.task_instance.id"] == "ti-9"
+
+    
@patch("airflow.providers.common.ai.operators.agent.stamp_identity_on_agent_spans",
 autospec=True)
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_regenerate_with_feedback_stamps_identity(self, mock_hook_cls, 
mock_stamp, make_mock_run_result):
+        """A HITL re-run stamps the same identity the initial run resolved."""
+        mock_agent = _make_mock_agent("revised", make_mock_run_result)
+        mock_hook_cls.get_hook.return_value.create_agent.return_value = 
mock_agent
+
+        op = AgentOperator(task_id="t", prompt="run", llm_conn_id="c")
+        op._run_identity_attrs = {"airflow.task_instance.id": "ti-9"}
+        op.regenerate_with_feedback(feedback="more", message_history=[])
+
+        mock_stamp.assert_called_once_with(mock_agent, 
{"airflow.task_instance.id": "ti-9"})
diff --git a/providers/common/ai/tests/unit/common/ai/test_observability.py 
b/providers/common/ai/tests/unit/common/ai/test_observability.py
index 4b0c84f7e50..91f4e8c91df 100644
--- a/providers/common/ai/tests/unit/common/ai/test_observability.py
+++ b/providers/common/ai/tests/unit/common/ai/test_observability.py
@@ -17,11 +17,15 @@
 from __future__ import annotations
 
 import json
+from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
 
+import pytest
 from opentelemetry.sdk.trace import TracerProvider
 from opentelemetry.sdk.trace.export import SimpleSpanProcessor
 from opentelemetry.sdk.trace.export.in_memory_span_exporter import 
InMemorySpanExporter
+from pydantic_ai import Agent
+from pydantic_ai.models.instrumented import InstrumentationSettings
 from pydantic_ai.models.test import TestModel
 
 from airflow.providers.common.ai import observability
@@ -141,3 +145,87 @@ class TestEndToEndSpanEmission:
         assert genai
         # With the opt-in, the prompt text is present on the spans.
         assert self._PROMPT in attrs_blob
+
+
+class TestBuildRunIdentityAttributes:
+    @pytest.mark.parametrize(("map_index", "expected_map_index"), [(3, 3), 
(None, -1)])
+    def test_builds_expected_attributes(self, map_index, expected_map_index):
+        ti = SimpleNamespace(
+            id="ti-1", dag_id="d", task_id="t", run_id="r", try_number=2, 
map_index=map_index
+        )
+        assert observability.build_run_identity_attributes(ti) == {
+            "airflow.dag_id": "d",
+            "airflow.task_id": "t",
+            "airflow.dag_run.run_id": "r",
+            "airflow.task_instance.try_number": 2,
+            "airflow.task_instance.map_index": expected_map_index,
+            "airflow.task_instance.id": "ti-1",
+        }
+
+
+class TestStampIdentityOnAgentSpans:
+    _ATTRS = {
+        "airflow.dag_id": "d",
+        "airflow.task_id": "t",
+        "airflow.dag_run.run_id": "r",
+        "airflow.task_instance.try_number": 2,
+        "airflow.task_instance.map_index": -1,
+        "airflow.task_instance.id": "ti-xyz",
+    }
+
+    def test_attributes_and_run_id_land_on_genai_spans(self):
+        exporter = InMemorySpanExporter()
+        provider = TracerProvider()
+        provider.add_span_processor(SimpleSpanProcessor(exporter))
+
+        hook = PydanticAIHook(llm_conn_id="c", model_id="test")
+        with (
+            patch.object(observability, "conf", _conf(enabled=True)),
+            patch.object(observability, "_live_tracer_provider", 
return_value=provider),
+            patch.object(hook, "get_conn", return_value=TestModel()),
+        ):
+            agent = hook.create_agent(instructions="be helpful")
+            observability.stamp_identity_on_agent_spans(agent, self._ATTRS)
+            agent.run_sync("hi", run_id="ti-xyz")
+
+        genai = [
+            s
+            for s in exporter.get_finished_spans()
+            if s.attributes and any(k.startswith("gen_ai.") for k in 
s.attributes)
+        ]
+        assert genai, "expected gen_ai spans to be emitted"
+        # Every GenAI span carries the full Airflow identity, not just the 
agent-run span.
+        for span in genai:
+            for key, value in self._ATTRS.items():
+                assert span.attributes.get(key) == value
+        # run_id passed to the run surfaces as the OTel agent-call id.
+        assert any(s.attributes.get("gen_ai.agent.call.id") == "ti-xyz" for s 
in genai)
+
+    def test_noop_when_agent_not_instrumented(self):
+        # A non-InstrumentationSettings ``instrument`` (here ``False``) is left
+        # untouched: stamping must not wrap it or raise.
+        agent = Agent(TestModel())
+        agent.instrument = False
+        observability.stamp_identity_on_agent_spans(agent, self._ATTRS)
+        assert agent.instrument is False
+
+    def test_wraps_a_copy_without_mutating_or_nesting_shared_settings(self):
+        # A caller-owned settings object handed to several agents (or reused 
across
+        # HITL re-runs) must stay untouched: each agent gets its own wrapped 
copy,
+        # and every wrapper wraps the real tracer directly rather than nesting.
+        provider = TracerProvider()
+        shared = InstrumentationSettings(tracer_provider=provider)
+        original_tracer = shared.tracer
+
+        agent1, agent2 = Agent(TestModel()), Agent(TestModel())
+        agent1.instrument = shared
+        agent2.instrument = shared
+        observability.stamp_identity_on_agent_spans(agent1, self._ATTRS)
+        observability.stamp_identity_on_agent_spans(agent2, self._ATTRS)
+
+        assert shared.tracer is original_tracer
+        assert agent1.instrument is not shared
+        assert agent1.instrument is not agent2.instrument
+        assert isinstance(agent1.instrument.tracer, 
observability._IdentityTracer)
+        assert agent1.instrument.tracer._tracer is original_tracer
+        assert agent2.instrument.tracer._tracer is original_tracer

Reply via email to