From 99fd32a8eb74b9fc31eb140c6117d4650440ed8d Mon Sep 17 00:00:00 2001 From: poshinchen Date: Wed, 15 Jul 2026 15:22:38 -0400 Subject: [PATCH] fix: convert Langfuse AGENT observations regardless of parent Strands SDK traces nest the AGENT observation under a framework SPAN, so the null-parent gate on the combined CHAIN/AGENT branch dropped every Strands agent invocation. No AgentInvocationSpan was produced, and every evaluator calling _get_last_turn() failed with ValueError. Route AGENT to _convert_agent_invocation unconditionally; keep the null-parent check on CHAIN, where only the root chain is the agent invocation (LangChain emits a CHAIN per sub-chain). Fixes #311 --- .../providers/langfuse_provider.py | 13 ++++- .../providers/test_langfuse_provider.py | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/strands_evals/providers/langfuse_provider.py b/src/strands_evals/providers/langfuse_provider.py index 3504a2b9..75870682 100644 --- a/src/strands_evals/providers/langfuse_provider.py +++ b/src/strands_evals/providers/langfuse_provider.py @@ -206,11 +206,17 @@ def _convert_observation(self, obs: Any, session_id: str) -> Any: Routing: obs.type == "GENERATION" → InferenceSpan obs.type == "TOOL" → ToolExecutionSpan + obs.type == "AGENT" → AgentInvocationSpan (Langfuse v4+) obs.type == "CHAIN" and no parent → AgentInvocationSpan - obs.type == "AGENT" and no parent → AgentInvocationSpan (Langfuse v4+) obs.type == "SPAN", name starts "execute_tool" → ToolExecutionSpan obs.type == "SPAN", name starts "invoke_agent" → AgentInvocationSpan Otherwise → None (skipped) + + Note: AGENT observations are converted regardless of parent. Strands SDK + traces nest the AGENT observation under a framework SPAN, so requiring a + null parent (as CHAIN does) would drop every Strands agent invocation. + CHAIN keeps the null-parent check because LangChain emits a CHAIN per + sub-chain and only the root chain is the agent invocation. """ obs_type = obs.type @@ -220,7 +226,10 @@ def _convert_observation(self, obs: Any, session_id: str) -> Any: if obs_type == "TOOL": return self._convert_tool_execution(obs, session_id) - if obs_type in ("CHAIN", "AGENT") and obs.parent_observation_id is None: + if obs_type == "AGENT": + return self._convert_agent_invocation(obs, session_id) + + if obs_type == "CHAIN" and obs.parent_observation_id is None: return self._convert_agent_invocation(obs, session_id) # Strands-specific fallback for SPAN type diff --git a/tests/strands_evals/providers/test_langfuse_provider.py b/tests/strands_evals/providers/test_langfuse_provider.py index 7b74a2c7..4666707a 100644 --- a/tests/strands_evals/providers/test_langfuse_provider.py +++ b/tests/strands_evals/providers/test_langfuse_provider.py @@ -872,6 +872,59 @@ def test_chain_with_content_output(self, provider, mock_client): assert agents[0].agent_response == "Hello!" +class TestLangfuseAgentType: + """AGENT-type observations (Langfuse v4+ / Strands via OTEL).""" + + def _get_spans(self, provider, mock_client, observations): + mock_client.api.trace.list.return_value = _paginated([_trace("t1", "s1")]) + mock_client.api.observations.get_many.return_value = _paginated(observations) + return provider.get_evaluation_data("s1")["trajectory"].traces[0].spans + + def test_agent_with_parent_produces_agent_invocation(self, provider, mock_client): + """AGENT nested under a SPAN parent → AgentInvocationSpan (issue #311).""" + spans = self._get_spans( + provider, + mock_client, + [ + _obs( + "o-agent", + "t1", + "AGENT", + name="invoke_agent Strands Agents", + obs_input="What is 2+2?", + obs_output="4", + parent_observation_id="o-span", + ), + ], + ) + agents = [s for s in spans if isinstance(s, AgentInvocationSpan)] + assert len(agents) == 1 + assert agents[0].user_prompt == "What is 2+2?" + assert agents[0].agent_response == "4" + + def test_root_agent_produces_agent_invocation(self, provider, mock_client): + """AGENT at the root (no parent) → AgentInvocationSpan.""" + spans = self._get_spans( + provider, + mock_client, + [ + _obs( + "o-agent", + "t1", + "AGENT", + name="invoke_agent Strands Agents", + obs_input="Hello", + obs_output="Hi!", + parent_observation_id=None, + ), + ], + ) + agents = [s for s in spans if isinstance(s, AgentInvocationSpan)] + assert len(agents) == 1 + assert agents[0].user_prompt == "Hello" + assert agents[0].agent_response == "Hi!" + + class TestLangChainEndToEnd: """Full LangChain agent trace: CHAIN + GENERATION + TOOL."""