Skip to content
Merged
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
206 changes: 198 additions & 8 deletions src/strands_evals/mappers/openinference_session_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- openinference.instrumentation.langchain (LangChain / LangGraph)
- openinference.instrumentation.smolagents (HuggingFace smolagents)
- openinference.instrumentation.claude_agent_sdk (Claude Agent SDK)
- openinference.instrumentation.openai_agents (OpenAI Agents SDK)

Each producer emits spans following the OpenInference semantic conventions but
with producer-specific encoding differences (e.g. attribute paths for message
Expand Down Expand Up @@ -38,11 +39,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 All @@ -67,12 +69,13 @@ class OpenInferenceSessionMapper(SessionMapper):
- openinference-instrumentation-langchain (LangChain / LangGraph)
- openinference-instrumentation-smolagents (HuggingFace smolagents)
- openinference-instrumentation-claude-agent-sdk (Claude Agent SDK)
- openinference-instrumentation-openai-agents (OpenAI Agents SDK)
Comment thread
JackYPCOnline marked this conversation as resolved.

Span type identification uses the openinference.span.kind attribute:
- Inference spans: "LLM"
- Tool execution spans: "TOOL"
- Agent invocation spans: "AGENT" (smolagents CodeAgent.run, Claude Agent SDK query) or
"CHAIN" with name="LangGraph" (LangGraph root graph)
- Agent invocation spans: "AGENT" (smolagents CodeAgent.run, Claude Agent SDK query,
OpenAI Agents SDK agent) or "CHAIN" with name="LangGraph" (LangGraph root graph)

Producer-specific encoding differences (e.g. message attribute paths,
tool argument wrapping) are normalized before shared conversion logic runs.
Expand All @@ -90,6 +93,8 @@ def __init__(self):
self._trace_tools_map: dict[str, dict[str, ToolConfig]] = defaultdict(dict)
# Track system prompts per trace
self._trace_system_prompt_map: dict[str, str] = defaultdict(str)
# Track span_id -> parent_span_id for ALL raw spans (including non-OpenInference scopes)
self._raw_parent_map: dict[str, str | None] = {}
# Cache: span_id -> (input_messages, output_messages) from _get_messages_from_span_events
# Avoids re-parsing span_events body during detection and again during conversion.
self._span_messages_cache: dict[str, tuple[list[dict], list[dict]]] = {}
Expand Down Expand Up @@ -118,21 +123,39 @@ def map_to_session(self, data: Any, session_id: str) -> Session:
# Normalize input to flat spans
spans = self._normalize_to_flat_spans(data)

# Build parent map from ALL spans
self._raw_parent_map = {s.get("span_id", ""): s.get("parent_span_id") for s in spans}

# Filter to only spans from this scope (including smolagents variant)
openinference_spans = [s for s in spans if self._get_scope_name(s) in SCOPES_OPENINFERENCE_FAMILY]

# 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:
span_id = span.get("span_id", "unknown")
logger.warning("scope=<%s>, span_id=<%s> | failed to normalize span: %s", scope, span_id, 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("failed to normalize openai agents trace: %s", e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: This warning doesn't follow the structured log format in STYLE_GUIDE.md (context fields as field=<%s> at the front) and drops the trace identifier, so a failed normalization is hard to correlate with a specific trace. Compare the per-span handler just above (line 143) which does it well.

Suggestion: Include the trace id as a structured field, e.g. logger.warning("trace_id=<%s> | failed to normalize openai agents trace: %s", trace_id, e). (Minor / non-blocking.)


# Build traces
result_traces: list[Trace] = []
for trace_id, trace_spans in grouped.items():
Expand Down Expand Up @@ -258,6 +281,107 @@ 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})

def _normalize_openai_agents_span(self, span: dict) -> None:
"""Normalize OpenAI Agents 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 self._extract_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 trace in-place.

This normalizes by:
1. Copying the user prompt, assistant response, and tool schemas from
LLM spans to parent AGENT spans
2. Falling back the assistant response to the last tool calls in the message
"""
# 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
or self._get_scope_name(span) != SCOPE_OPENINFERENCE_OPENAI_AGENTS
):
continue

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

first_attrs = llm_spans[0].get("attributes", {})
user_prompt = self._extract_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 for available_tools back-filling
for tool_idx in self._extract_message_indices(first_attrs, "llm.tools"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: available_tools back-fill copies llm.tools.* only from first_attrs (the earliest LLM span in the agent subtree). If an agent's tool set is only present on a later turn's LLM span (e.g. tools attached lazily), those schemas won't be surfaced on the AGENT span.

Suggestion: Low risk for the OpenAI Agents SDK (tool set is stable per agent), so fine to leave — but consider unioning llm.tools.* across all descendant LLM spans if you ever see this in practice. (Suggestion / non-blocking.)

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._extract_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._extract_last_tool_calls(last_attrs)
if tool_calls:
agent_response = f"[delegated] {tool_calls}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: "[delegated] ..." is a synthetic sentinel that ends up as the agent's agent_response — i.e. it becomes text a judge/evaluator scores, and the tests couple to the literal ("[delegated]" in agent_response). A raw string literal used both as evaluator-visible content and as a test contract is easy to drift.

Suggestion: Promote the prefix to a named module constant (e.g. DELEGATED_RESPONSE_PREFIX) and reference it from both the mapper and tests. Worth a one-line note on why delegation turns are surfaced this way. (Suggestion / non-blocking.)

if agent_response:
attrs["output.value"] = agent_response

def _collect_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]
# Traverse the spans in top-down order
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" and self._get_scope_name(child) == SCOPE_OPENINFERENCE_OPENAI_AGENTS:
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

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 Down Expand Up @@ -305,6 +429,9 @@ def _build_trace(self, trace_id: str, spans: list[dict], session_id: str) -> Tra
if isinstance(converted, AgentInvocationSpan) and not converted.system_prompt:
converted.system_prompt = system_prompt

# Fix parent_span_id on converted spans that point to skipped intermediaries.
converted_spans = bridge_parent_gaps(converted_spans, self._raw_parent_map)

return Trace(spans=converted_spans, trace_id=trace_id, session_id=session_id)

# =========================================================================
Expand Down Expand Up @@ -380,7 +507,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 @@ -636,7 +767,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 All @@ -657,6 +788,65 @@ def _convert_agent_invocation_span(self, span: dict, session_id: str) -> AgentIn
# Helper Methods
# =========================================================================

def _extract_message_indices(self, attrs: dict, prefix: str) -> list[int]:
"""Return the numeric indices N found in keys starting with `{prefix}.N.`, highest first."""
prefix_dot = f"{prefix}."
indices: set[int] = set()
for key in attrs:
if key.startswith(prefix_dot):
seg = key.removeprefix(prefix_dot).split(".", 1)[0]
if seg.isascii() and seg.isdigit():
indices.add(int(seg))
return sorted(indices, reverse=True)

def _extract_last_message_text(self, attrs: dict, prefix: str, role: str | None = None) -> str | None:
"""Return the text of the last matching message, or None."""
for idx in self._extract_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 self._extract_text_from_content_parts(attrs, base)
if role_match and not is_reasoning and text:
return text
return None

def _extract_text_from_content_parts(self, 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

def _extract_last_tool_calls(self, 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 self._extract_message_indices(attrs, prefix):
base = f"{prefix}.{idx}.message"
calls: list[str] = []
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

@staticmethod
def _extract_llm_metadata(attrs: dict) -> dict:
"""Extract token counts and model name from span attributes into metadata."""
Expand Down Expand Up @@ -833,7 +1023,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