Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/strands_evals/providers/langfuse_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/strands_evals/providers/test_langfuse_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading