diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f11fdd7..8198becd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Other Changes - Contribute Microsoft distro profile information (`component="mot"` and distro version) to the OneSettings control plane during `use_microsoft_opentelemetry()`. +### Features Added +- Add support for agent identity propagation for compiled agents in nested graph + ([#245](https://github.com/microsoft/opentelemetry-distro-python/pull/245)) # 1.3.7 (2026-08-05) ### Features Added diff --git a/src/microsoft/opentelemetry/_genai/_langchain/_tracer.py b/src/microsoft/opentelemetry/_genai/_langchain/_tracer.py index 610b05f7..f6bd7df1 100644 --- a/src/microsoft/opentelemetry/_genai/_langchain/_tracer.py +++ b/src/microsoft/opentelemetry/_genai/_langchain/_tracer.py @@ -386,6 +386,18 @@ def _langgraph_node_name(cls, run: Run) -> str | None: node = cls._run_metadata(run).get("langgraph_node") return str(node) if node else None + @classmethod + def _is_subgraph_boundary(cls, run: Run) -> bool: + """Return ``True`` when this run is the invocation of a nested compiled + graph (a subgraph / compiled agent used as a node inside an outer graph).""" + node = cls._langgraph_node_name(run) + if not node: + return False + name = str(run.name) if run.name else "" + if not name or name == "LangGraph": + return False + return name != node + def _should_ignore_langgraph_node(self, run: Run) -> bool: # pylint: disable=too-many-return-statements """Decide whether a genuine LangGraph node should be suppressed.""" meta = self._run_metadata(run) @@ -406,7 +418,11 @@ def _should_ignore_langgraph_node(self, run: Run) -> bool: # pylint: disable=to # 4. The compiled-graph root (no parent) is always emitted. if run.parent_run_id is None: return False - # 5. A nested node is a genuine sub-agent only when it advertises an + # 5. A nested subgraph boundary (a compiled agent invoked as a node + # inside an outer graph) is a genuine agent and must be emitted. + if self._is_subgraph_boundary(run): + return False + # 6. A nested node is a genuine sub-agent only when it advertises an # explicit identity; otherwise it is an internal orchestration node # (create_agent's ``model`` / ``tools``) and is suppressed. if meta.get("agent_name") or meta.get("agent_type"): @@ -458,9 +474,10 @@ def _resolve_agent_name(self, run: Run, *, use_config: bool = True) -> str | Non if name := meta.get("agent_type"): return str(name) # 2. LangGraph structural node name (framework-injected per node). - if node := meta.get("langgraph_node"): - if str(node) not in ("", "LangGraph", self._LANGGRAPH_START_NODE): - return str(node) + if not self._is_subgraph_boundary(run): + if node := meta.get("langgraph_node"): + if str(node) not in ("", "LangGraph", self._LANGGRAPH_START_NODE): + return str(node) if name := meta.get("lc_agent_name"): return str(name) # 3. Process-level config default (top-level agent only). diff --git a/tests/langchain/test_tracer.py b/tests/langchain/test_tracer.py index 74bafb50..b36903f5 100644 --- a/tests/langchain/test_tracer.py +++ b/tests/langchain/test_tracer.py @@ -203,6 +203,18 @@ def _node_run(node_name, *, parent_run_id=None, **meta): ) +def _subgraph_body_run(graph_name, node_name, *, parent_run_id=None, **meta): + """Build the *body* run of a nested compiled graph (subgraph / compiled + agent) invoked as node ``node_name`` inside an outer graph.""" + metadata = {"langgraph_node": node_name, **meta} + return _make_run( + run_type="chain", + name=graph_name, + parent_run_id=parent_run_id or uuid4(), + extra={"metadata": metadata}, + ) + + class TestShouldIgnoreLangGraphNode(TestCase): """Guards the suppression rules for genuine LangGraph nodes. These rules decide which framework-internal nodes (``__start__``, middleware, @@ -275,6 +287,45 @@ def test_nested_identityless_node_ignored(self): run = _node_run("model", parent_run_id=uuid4()) self.assertTrue(self.tracer._should_ignore_langgraph_node(run)) + def test_nested_subgraph_boundary_kept(self): + run = _subgraph_body_run("Travel_Assistant", "assistant", parent_run_id=uuid4()) + self.assertFalse(self.tracer._should_ignore_langgraph_node(run)) + + +class TestIsSubgraphBoundary(TestCase): + """The ``run.name != langgraph_node`` rule that auto-detects a nested + compiled graph (subgraph / compiled agent) invoked as a node inside an + outer graph -- the framework-native signal used to emit nested agents + without requiring the user to supply metadata.""" + + def test_subgraph_body_run_is_boundary(self): + run = _subgraph_body_run("Travel_Assistant", "assistant") + self.assertTrue(LangChainTracer._is_subgraph_boundary(run)) + + def test_task_run_is_not_boundary(self): + run = _make_run( + run_type="chain", + name="model", + extra={"metadata": {"langgraph_node": "model"}}, + ) + self.assertFalse(LangChainTracer._is_subgraph_boundary(run)) + + def test_generic_langgraph_name_is_not_boundary(self): + run = _node_run("model", parent_run_id=uuid4()) + self.assertFalse(LangChainTracer._is_subgraph_boundary(run)) + + def test_no_langgraph_node_is_not_boundary(self): + run = _make_run(run_type="chain", name="Travel_Assistant") + self.assertFalse(LangChainTracer._is_subgraph_boundary(run)) + + def test_empty_run_name_is_not_boundary(self): + run = _make_run( + run_type="chain", + name="", + extra={"metadata": {"langgraph_node": "assistant"}}, + ) + self.assertFalse(LangChainTracer._is_subgraph_boundary(run)) + # ---- Agent name resolution --------------------------------------------------- @@ -331,6 +382,15 @@ def test_langgraph_node_used_when_no_explicit_identity(self): ) self.assertEqual(tracer._resolve_agent_name(run), "researcher") + def test_subgraph_boundary_resolves_to_graph_name(self): + """At a nested subgraph boundary the wrapper ``langgraph_node`` name is + skipped and the compiled graph's own ``run.name`` supplies the agent + identity (so a nested ``create_agent(name=...)`` renders under its real + name, not the outer wrapper node name).""" + tracer, _, _ = _make_tracer() + run = _subgraph_body_run("Travel_Assistant", "assistant") + self.assertEqual(tracer._resolve_agent_name(run), "Travel_Assistant") + def test_langgraph_start_node_skipped(self): """The ``__start__`` entrypoint node name is never used as a label; resolution falls through to the next signal (here, none -> None).""" @@ -663,6 +723,79 @@ def test_unresolvable_ancestor_name_does_not_suppress(self, mock_ctx): self.assertIn(child.id, tracer._spans_by_run) +class TestNestedSubgraphEmission(TestCase): + """End-to-end ``_start_trace`` behaviour for compiled agents auto-detected + as nested subgraph boundaries (``run.name != langgraph_node``)""" + + @patch("microsoft.opentelemetry._genai._langchain._tracer.context_api") + def test_nested_compiled_agent_emits_span_with_graph_name(self, mock_ctx): + mock_ctx.get_value.return_value = None + tracer, otel_tracer, mock_span = _make_tracer() + run = _subgraph_body_run("Travel_Assistant", "assistant", parent_run_id=uuid4()) + tracer._start_trace(run) + otel_tracer.start_span.assert_called_once() + span_name = otel_tracer.start_span.call_args.kwargs["name"] + self.assertEqual(span_name, f"{INVOKE_AGENT_OPERATION_NAME} Travel_Assistant") + mock_span.set_attribute.assert_any_call(GEN_AI_AGENT_NAME_KEY, "Travel_Assistant") # pylint: disable=no-member + self.assertIn(run.id, tracer._spans_by_run) + self.assertIn(run.id, tracer._agent_run_ids) + + @patch("microsoft.opentelemetry._genai._langchain._tracer.context_api") + def test_two_level_nesting_emits_both_agents(self, mock_ctx): + mock_ctx.get_value.return_value = None + tracer, otel_tracer, _ = _make_tracer() + coordinator_span = MagicMock(name="coordinator") + assistant_span = MagicMock(name="assistant") + otel_tracer.start_span.side_effect = [coordinator_span, assistant_span] + + # Outer graph invokes the coordinator subgraph as node ``coordinator``. + coordinator = _subgraph_body_run("Travel_Coordinator", "coordinator", parent_run_id=uuid4()) + with patch("microsoft.opentelemetry._genai._langchain._tracer.trace_api.set_span_in_context"): + tracer._start_trace(coordinator) + + # Coordinator invokes the assistant subgraph as node ``assistant``. + assistant = _subgraph_body_run("Travel_Assistant", "assistant", parent_run_id=coordinator.id) + with patch("microsoft.opentelemetry._genai._langchain._tracer.trace_api.set_span_in_context"): + tracer._start_trace(assistant) + + self.assertEqual(otel_tracer.start_span.call_count, 2) + names = [c.kwargs["name"] for c in otel_tracer.start_span.call_args_list] + self.assertEqual( + names, + [ + f"{INVOKE_AGENT_OPERATION_NAME} Travel_Coordinator", + f"{INVOKE_AGENT_OPERATION_NAME} Travel_Assistant", + ], + ) + self.assertIn(coordinator.id, tracer._agent_run_ids) + self.assertIn(assistant.id, tracer._agent_run_ids) + + @patch("microsoft.opentelemetry._genai._langchain._tracer.context_api") + def test_internal_node_of_nested_agent_is_suppressed(self, mock_ctx): + mock_ctx.get_value.return_value = None + tracer, otel_tracer, _ = _make_tracer() + agent_span = MagicMock(name="agent") + otel_tracer.start_span.side_effect = [agent_span] + + agent = _subgraph_body_run("Travel_Assistant", "assistant", parent_run_id=uuid4()) + with patch("microsoft.opentelemetry._genai._langchain._tracer.trace_api.set_span_in_context"): + tracer._start_trace(agent) + otel_tracer.start_span.reset_mock() + + # The agent's internal model node: run.name == langgraph_node -> not a + # boundary, no explicit identity -> suppressed. + model_node = _make_run( + run_type="chain", + name="model", + parent_run_id=agent.id, + extra={"metadata": {"langgraph_node": "model"}}, + ) + tracer._start_trace(model_node) + otel_tracer.start_span.assert_not_called() + self.assertNotIn(model_node.id, tracer._spans_by_run) + self.assertIn(str(model_node.id), tracer.run_map) + + class TestEndTrace(TestCase): @patch("microsoft.opentelemetry._genai._langchain._tracer.context_api") def test_ends_span(self, mock_ctx):