pankajkoti commented on code in PR #73275:
URL: https://github.com/apache/airflow/pull/73275#discussion_r4037288867


##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -573,10 +594,29 @@ def _emit_message_history(self, context: Context, result: 
Any) -> None:
         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."""
+        usage = result.usage
+        ti = context["task_instance"]
+        ti.xcom_push(key="run_id", value=result.run_id)

Review Comment:
   Good point, gated it. `_emit_run_metadata` now returns early when 
`do_xcom_push` is False, so the `run_id` and `usage` pushes respect the flag 
the same way the rest of the operator's XCom does. Done in bcc285d7af.
   



##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -470,7 +481,16 @@ def execute(self, context: Context) -> Any:
 
         agent = self._build_agent()
 
+        ti = context["task_instance"]
+        self._run_identity_attrs = build_run_identity_attributes(ti)
+        stamp_identity_on_agent_spans(agent, self._run_identity_attrs)
+
         run_kwargs: dict[str, Any] = {"usage_limits": usage_limits}
+        if ti.id is not None:

Review Comment:
   You're right, `TaskInstance.id` is a non-nullable `UUID` on the Task SDK 
datamodel, so the guard was dead code and the test was asserting a state the 
runtime cannot produce. Dropped the guard so `run_id` is always `str(ti.id)`, 
which also makes it consistent with `build_run_identity_attributes`, and 
removed that test. Done in bcc285d7af.
   



##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -573,10 +594,29 @@ def _emit_message_history(self, context: Context, result: 
Any) -> None:
         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."""
+        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,

Review Comment:
   Added `cost` to the `usage` payload rather than trimming the docstring. The 
floor is already at `pydantic-ai-slim>=2.23.0` (bumped for per-run cost limits 
in #71403), and `RunUsage.cost` ships in that same 2.23.0 cost API, so 
surfacing it needs no further bump. It is `Decimal | None`, so it is 
stringified before it goes to XCom. Done in bcc285d7af.
   



##########
providers/common/ai/src/airflow/providers/common/ai/observability.py:
##########
@@ -106,3 +111,71 @@ 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).
+    """
+    from pydantic_ai.models.instrumented import InstrumentationSettings
+
+    instrument = agent.instrument
+    if isinstance(instrument, InstrumentationSettings):
+        # _IdentityTracer implements the Tracer surface structurally (it cannot
+        # subclass Tracer without importing opentelemetry at module load).
+        instrument.tracer = cast("Tracer", _IdentityTracer(instrument.tracer, 
attributes))

Review Comment:
   Confirmed both the shared-settings leak and the HITL nesting. Now 
`copy.copy` the settings, wrap the copy's tracer, and assign it to 
`agent.instrument`, leaving the caller's object untouched, so each run gets its 
own single wrapper. Verified `agent.instrument` is read at run time via 
`_resolve_instrumentation_settings`, so the swapped copy is the one used. Done 
in bcc285d7af.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to