Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/strands_evals/mappers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
SCOPE_OPENINFERENCE = "openinference.instrumentation.langchain"
SCOPE_OPENINFERENCE_SMOLAGENTS = "openinference.instrumentation.smolagents"
SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK = "openinference.instrumentation.claude_agent_sdk"
SCOPE_OPENINFERENCE_OPENAI_AGENTS = "openinference.instrumentation.openai_agents"
SCOPE_ADK = "gcp.vertex.agent"
SCOPE_STRANDS = "strands.telemetry.tracer"

Expand All @@ -18,6 +19,7 @@
SCOPE_OPENINFERENCE,
SCOPE_OPENINFERENCE_SMOLAGENTS,
SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK,
SCOPE_OPENINFERENCE_OPENAI_AGENTS,
]
)

Expand Down
193 changes: 187 additions & 6 deletions src/strands_evals/mappers/openinference_session_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@
from .constants import (
SCOPE_OPENINFERENCE,
SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK,
SCOPE_OPENINFERENCE_OPENAI_AGENTS,
SCOPE_OPENINFERENCE_SMOLAGENTS,
SCOPES_OPENINFERENCE_FAMILY,
)
from .session_mapper import SessionMapper
from .utils import safe_json_parse
from .utils import bridge_parent_gaps, safe_json_parse

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -124,15 +125,29 @@ def map_to_session(self, data: Any, session_id: str) -> Session:
# Per-producer normalization: canonicalize encoding differences so the
# shared conversion logic receives a uniform representation.
for span in openinference_spans:
if self._get_scope_name(span) == SCOPE_OPENINFERENCE_SMOLAGENTS:
self._normalize_smolagents_span(span)
scope = self._get_scope_name(span)
try:
if scope == SCOPE_OPENINFERENCE_SMOLAGENTS:
self._normalize_smolagents_span(span)
elif scope == SCOPE_OPENINFERENCE_OPENAI_AGENTS:
self._normalize_openai_agents_span(span)
except Exception as e:
logger.warning(f"Failed to normalize {scope} span {span.get('span_id', 'unknown')}: {e}")

# Group spans by trace_id
grouped = defaultdict(list)
for span in openinference_spans:
trace_id = span.get("trace_id", "")
grouped[trace_id].append(span)

# OpenAI Agents SDK normalization requires the full trace group, so it runs after grouping.
for trace_spans in grouped.values():
if any(self._get_scope_name(s) == SCOPE_OPENINFERENCE_OPENAI_AGENTS for s in trace_spans):
try:
self._normalize_openai_agents_trace(trace_spans)
except Exception as e:
logger.warning(f"Failed to normalize openai agents trace: {e}")

# Build traces
result_traces: list[Trace] = []
for trace_id, trace_spans in grouped.items():
Expand Down Expand Up @@ -258,6 +273,164 @@ def _normalize_smolagents_tool_attrs(self, attrs: dict) -> None:
# Fallback: no tool.parameters available to map positional args
attrs["input.value"] = json.dumps({"args": args})

@classmethod
def _normalize_openai_agents_span(cls, span: dict) -> None:
"""Normalize OpenAI agent spans in-place.

This normalizes by:
1. Unwrapping tool schemas in the format {"type": "function", "function": {...}}.
2. Aliasing "parameters" to "input_schema" on tool schemas.
"""
attrs = span.get("attributes") or {}
for idx in cls._get_message_indices(attrs, "llm.tools."):
key = f"llm.tools.{idx}.tool.json_schema"
raw = attrs.get(key)
if isinstance(raw, str):
try:
schema = json.loads(raw)
except json.JSONDecodeError:
continue
else:
schema = raw
if not isinstance(schema, dict):
continue
if "name" not in schema and isinstance(schema.get("function"), dict):
schema = schema["function"]
if "input_schema" not in schema and "parameters" in schema:
schema["input_schema"] = schema["parameters"]
attrs[key] = json.dumps(schema)

def _normalize_openai_agents_trace(self, spans: list[dict]) -> None:
"""Normalize OpenAI Agents SDK AGENT spans in-place by injecting input/output.

OpenAI Agents SDK AGENT spans carry no input.value or output.value.
The user prompt and final response live on descendant LLM spans
(reachable through intermediate CHAIN "turn" spans).
"""
# Collect all spans keyed by parent id for span traversal
spans_by_parent_id: dict[str, list[dict]] = defaultdict(list)
for s in spans:
parent = s.get("parent_span_id")
if parent:
spans_by_parent_id[parent].append(s)

for span in spans:
attrs = span.get("attributes") or {}
span_id = span.get("span_id", "")
if attrs.get("openinference.span.kind") != "AGENT" or attrs.get("input.value") or not span_id:
continue

llm_spans = self._get_descendant_llm_spans(span_id, spans_by_parent_id)
if not llm_spans:
continue

first_attrs = llm_spans[0].get("attributes", {})
user_prompt = self._get_last_message_text(first_attrs, "llm.input_messages.", role="user")
if user_prompt:
attrs["input.value"] = user_prompt

# Copy LLM tool schemas onto the AGENT span so it carries its own tools.
for tool_idx in self._get_message_indices(first_attrs, "llm.tools."):
key = f"llm.tools.{tool_idx}.tool.json_schema"
schema = first_attrs.get(key)
if schema is not None:
attrs[key] = schema

last_attrs = llm_spans[-1].get("attributes", {})
agent_response = self._get_last_message_text(last_attrs, "llm.output_messages.")

# Fallback the response to the last message's tool calls if no LLM output was found
if not agent_response:
tool_calls = self._get_last_tool_calls(last_attrs)
if tool_calls:
agent_response = f"[delegated] {tool_calls}"
if agent_response:
attrs["output.value"] = agent_response

def _get_descendant_llm_spans(self, span_id: str, spans_by_parent_id: dict[str, list[dict]]) -> list[dict]:
"""Return the LLM spans descending from `span_id`, earliest first."""
llm_spans: list[dict] = []
stack = [span_id]
seen: set[str] = set()
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
for child in spans_by_parent_id.get(current, []):
kind = (child.get("attributes") or {}).get("openinference.span.kind", "")
if kind == "LLM":
llm_spans.append(child)
elif kind == "CHAIN":
cid = child.get("span_id", "")
if cid:
stack.append(cid)
llm_spans.sort(key=lambda s: self.parse_timestamp(s.get("start_time")))
return llm_spans

@staticmethod
def _get_message_indices(attrs: dict, prefix: str) -> list[int]:
"""Return the message indices present under `prefix`, highest first."""
indices: set[int] = set()
for key in attrs:
if key.startswith(prefix):
seg = key.removeprefix(prefix).split(".", 1)[0]
if seg.isascii() and seg.isdigit():
indices.add(int(seg))
return sorted(indices, reverse=True)

@classmethod
def _get_last_message_text(cls, attrs: dict, prefix: str, role: str | None = None) -> str | None:
"""Return the text of the last matching message, or None."""
for idx in cls._get_message_indices(attrs, prefix):
base = f"{prefix}{idx}.message"
role_match = role is None or attrs.get(f"{base}.role") == role
is_reasoning = attrs.get(f"{base}.contents.0.message_content.type") == "reasoning"
text = attrs.get(f"{base}.content") or cls._join_content_text(attrs, base)
if role_match and not is_reasoning and text:
return text
return None

@staticmethod
def _join_content_text(attrs: dict, base: str) -> str | None:
"""Concatenate all `text` parts under `{base}.contents.N`, or None.

Multimodal messages emit ordered parts (e.g. [image, text]), so scan every
part rather than only index 0.
"""
parts: list[str] = []
i = 0
while True:
part_type = attrs.get(f"{base}.contents.{i}.message_content.type")
if part_type is None:
break
if part_type == "text":
text = attrs.get(f"{base}.contents.{i}.message_content.text")
if text:
parts.append(text)
i += 1
return "".join(parts) or None

@classmethod
def _get_last_tool_calls(cls, attrs: dict) -> str | None:
"""Return a text rendering of the last assistant message's tool calls, or None."""
prefix = "llm.output_messages."
for idx in cls._get_message_indices(attrs, prefix):
base = f"{prefix}{idx}.message"
calls: list[str] = []
# Convert the tool calls to a string representation
i = 0
while True:
name = attrs.get(f"{base}.tool_calls.{i}.tool_call.function.name")
if not name:
break
args = attrs.get(f"{base}.tool_calls.{i}.tool_call.function.arguments", "")
calls.append(f"{name}({args})" if args else f"{name}()")
i += 1
if calls:
return "; ".join(calls)
return None

def _build_trace(self, trace_id: str, spans: list[dict], session_id: str) -> Trace:
"""Build a Trace from spans with the same trace_id."""
converted_spans: list[InferenceSpan | ToolExecutionSpan | AgentInvocationSpan] = []
Expand All @@ -279,6 +452,10 @@ def _build_trace(self, trace_id: str, spans: list[dict], session_id: str) -> Tra
except Exception as e:
logger.warning(f"Failed to convert span {span.get('span_id', 'unknown')}: {e}")

# Fix parent_span_id on converted spans that point to skipped intermediaries
raw_parent_map = {s.get("span_id", ""): s.get("parent_span_id") for s in spans}
bridge_parent_gaps(converted_spans, raw_parent_map)

# In multi-agent LangGraph systems, each nested sub-graph produces its own
# LangGraph CHAIN span. Keep only the last one (root graph finishes last).
agent_spans = [s for s in converted_spans if isinstance(s, AgentInvocationSpan)]
Expand Down Expand Up @@ -374,7 +551,11 @@ def _is_agent_invocation_span(self, span: dict) -> bool:
# routing nodes that aren't true agent invocations — reject those by default.
if span_kind == "AGENT":
scope_name = self._get_scope_name(span)
if scope_name in (SCOPE_OPENINFERENCE_SMOLAGENTS, SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK):
if scope_name in (
SCOPE_OPENINFERENCE_SMOLAGENTS,
SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK,
SCOPE_OPENINFERENCE_OPENAI_AGENTS,
):
input_val = attrs.get("input.value")
if input_val:
output_val = attrs.get("output.value")
Expand Down Expand Up @@ -625,7 +806,7 @@ def _convert_agent_invocation_span(self, span: dict, session_id: str) -> AgentIn
logger.warning(f"No agent_response for agent span {span.get('span_id')}")
return None

available_tools = sorted(
available_tools = self._extract_tools_from_attributes(attrs) or sorted(
self._trace_tools_map.get(trace_id, {}).values(),
key=lambda t: t.name,
)
Expand Down Expand Up @@ -816,7 +997,7 @@ def _extract_tools_from_attributes(self, attrs: dict) -> list[ToolConfig]:
parameters=tool_info.get("input_schema"),
)
)
except (json.JSONDecodeError, AttributeError):
except (json.JSONDecodeError, AttributeError, ValueError):
pass

return sorted(tools, key=lambda t: t.name or "")
Expand Down
Loading