Skip to content

Commit 9fb48f5

Browse files
committed
fix: reparent agent spans according to handoff attributes
1 parent 5b1142b commit 9fb48f5

2 files changed

Lines changed: 71 additions & 19 deletions

File tree

src/strands_evals/mappers/openai_agents_otel_session_mapper.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
(produced by opentelemetry-instrumentation-openai-agents from Traceloop's OpenLLMetry project)
55
66
Supports two trace formats:
7-
1. ADOT/CloudWatch: Messages in gen_ai.* attributes
8-
2. Live instrumentation: Messages in gen_ai.* attributes
7+
1. Live instrumentation: Messages in gen_ai.* attributes
8+
2. ADOT/CloudWatch: the same as above plus `aws.*` attributes
99
"""
1010

1111
import logging
@@ -30,9 +30,6 @@ class OpenAIAgentsOtelSessionMapper(GenericGenAISessionMapper):
3030
"""Maps OpenAI Agents SDK OTel spans to Session format.
3131
3232
Inherits from GenericGenAISessionMapper to reuse GenAI semantic conventions parsing.
33-
Overrides workflow-span skipping and tool back-filling which assume a single-agent-per-
34-
workflow trace shape and would break multi-agent traces or frameworks that use empty
35-
invoke_agent spans as session anchors if applied in the base class.
3633
"""
3734

3835
def map_to_session(self, data: Any, session_id: str) -> Session:
@@ -45,27 +42,23 @@ def map_to_session(self, data: Any, session_id: str) -> Session:
4542
Returns:
4643
Session object ready for evaluation.
4744
"""
48-
# Normalize input to flat spans
4945
spans = self._normalize_to_flat_spans(data)
5046

51-
# Filter to only spans from this scope
5247
openai_agents_spans = [s for s in spans if get_scope_name(s) in (SCOPE_OPENAI_AGENTS, "")]
5348

5449
return super().map_to_session(openai_agents_spans, session_id)
5550

5651
def _convert_trace(self, trace_id: str, spans: list[dict], session_id: str) -> Trace:
57-
"""Convert a list of dict spans to a Trace, with per-agent tool back-filling.
58-
59-
After base conversion (which sets agent_span_id via Trace.model_post_init),
60-
assigns each ToolExecutionSpan's tool only to its owning AgentInvocationSpan.
61-
"""
52+
"""Convert a list of dict spans to a Trace."""
6253
trace = super()._convert_trace(trace_id, spans, session_id)
6354

64-
# Back-fill available_tools scoped per agent using agent_span_id (set by model_post_init).
6555
agent_spans = {
6656
s.span_info.span_id: s for s in trace.spans if isinstance(s, AgentInvocationSpan) and s.span_info.span_id
6757
}
6858

59+
self._apply_handoff_reparenting(agent_spans, spans)
60+
61+
# Back-fill agent's available_tools, which is not otherwise provided in the trace.
6962
for span in trace.spans:
7063
if isinstance(span, ToolExecutionSpan) and span.tool_call.name:
7164
owner_id = span.agent_span_id
@@ -76,15 +69,45 @@ def _convert_trace(self, trace_id: str, spans: list[dict], session_id: str) -> T
7669

7770
return trace
7871

72+
def _apply_handoff_reparenting(
73+
self,
74+
agent_spans: dict[str, AgentInvocationSpan],
75+
raw_spans: list[dict],
76+
) -> None:
77+
"""Re-parent agent invocation spans using OpenAI Agent's agent_handoff span."""
78+
agent_spans_by_name: dict[str, AgentInvocationSpan] = {}
79+
handoffs: list[tuple[str, str]] = []
80+
81+
# Collect converted agent spans and handoff edges
82+
for raw_span in raw_spans:
83+
attrs = raw_span.get("attributes", {})
84+
op = attrs.get("gen_ai.operation.name", "")
85+
if op == "invoke_agent":
86+
name = attrs.get("gen_ai.agent.name", "")
87+
span_id = raw_span.get("span_id", "")
88+
if name and span_id and span_id in agent_spans:
89+
agent_spans_by_name[name] = agent_spans[span_id]
90+
elif op == "agent_handoff":
91+
from_name = attrs.get("gen_ai.handoff.from_agent", "")
92+
to_name = attrs.get("gen_ai.handoff.to_agent", "")
93+
if from_name and to_name:
94+
handoffs.append((from_name, to_name))
95+
96+
# Re-parent sub-agents to parent agents
97+
for from_name, to_name in handoffs:
98+
from_span = agent_spans_by_name.get(from_name)
99+
to_span = agent_spans_by_name.get(to_name)
100+
if from_span and to_span and from_span.span_info.span_id:
101+
to_span.span_info.parent_span_id = from_span.span_info.span_id
102+
79103
def _convert_agent_invocation_span(self, span: dict, span_info: SpanInfo) -> AgentInvocationSpan | None:
80104
"""Convert an 'invoke_agent' span to AgentInvocationSpan.
81105
82-
Skips the root "Agent workflow" span emitted by Traceloop's instrumentation.
83-
This span has invoke_agent operation but no agent_name or input messages —
84-
it's a structural wrapper, not a real agent invocation.
106+
Skips the root "Agent workflow" wrapper emitted by Traceloop's instrumentation.
85107
"""
86108
attrs = span.get("attributes", {})
87109

110+
# Agent workflow span has no agent_name or input messages
88111
if not attrs.get("gen_ai.agent.name") and not attrs.get("gen_ai.input.messages"):
89112
return None
90113

tests/strands_evals/mappers/test_openai_agents_otel_session_mapper.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,35 @@ def test_session_has_agent_invocation_spans(self, adot_session):
160160
agent_spans = [s for s in all_spans if isinstance(s, AgentInvocationSpan)]
161161
assert len(agent_spans) > 0
162162

163+
def test_handoff_reparents_math_specialist_under_coordinator(self, adot_session):
164+
"""agent_handoff span should re-parent math_specialist under coordinator."""
165+
all_spans = [s for t in adot_session.traces for s in t.spans]
166+
agent_spans = [s for s in all_spans if isinstance(s, AgentInvocationSpan)]
167+
168+
coordinator = next(s for s in agent_spans if s.span_info.span_id == "1afcb5ca9fe89dda")
169+
math_specialist = next(s for s in agent_spans if s.span_info.span_id == "c2828d53371818f0")
170+
171+
assert math_specialist.span_info.parent_span_id == coordinator.span_info.span_id
172+
agent_span_ids = {s.span_info.span_id for s in agent_spans}
173+
assert coordinator.span_info.parent_span_id not in agent_span_ids
174+
175+
def test_adot_coordinator_tools_empty(self, adot_session):
176+
"""ADOT coordinator has no structured tool calls (handoffs emitted as repr text)."""
177+
all_spans = [s for t in adot_session.traces for s in t.spans]
178+
coordinator = next(
179+
s for s in all_spans if isinstance(s, AgentInvocationSpan) and s.span_info.span_id == "1afcb5ca9fe89dda"
180+
)
181+
assert coordinator.available_tools == []
182+
183+
def test_adot_math_specialist_tools(self, adot_session):
184+
"""ADOT math_specialist should have multiply_numbers back-filled."""
185+
all_spans = [s for t in adot_session.traces for s in t.spans]
186+
math_specialist = next(
187+
s for s in all_spans if isinstance(s, AgentInvocationSpan) and s.span_info.span_id == "c2828d53371818f0"
188+
)
189+
tool_names = sorted(t.name for t in math_specialist.available_tools)
190+
assert tool_names == ["multiply_numbers"]
191+
163192

164193
# =============================================================================
165194
# Per-Agent Tool Attribution Tests
@@ -194,8 +223,8 @@ def test_math_specialist_tools(self, live_session):
194223
assert tool_names == ["divide_numbers", "multiply_numbers"]
195224

196225
def test_tools_not_shared_across_agents(self, live_session):
197-
"""No tool should appear in both agents' available_tools lists."""
226+
"""No tool should appear in multiple agents' available_tools lists."""
198227
all_spans = [s for t in live_session.traces for s in t.spans]
199228
agent_spans = [s for s in all_spans if isinstance(s, AgentInvocationSpan)]
200-
tool_sets = [frozenset(t.name for t in s.available_tools) for s in agent_spans]
201-
assert tool_sets[0] & tool_sets[1] == frozenset()
229+
tool_sets = [set(t.name for t in s.available_tools) for s in agent_spans]
230+
assert not set.intersection(*tool_sets)

0 commit comments

Comments
 (0)