Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
207 changes: 199 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

how do we pick up this naming? Wondering claude-agent-sdk but for openai agent sdk we only do openai-agents

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are the official package names from the OpenInference repository: https://github.com/Arize-ai/openinference/tree/main

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks!


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)

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

@JackYPCOnline JackYPCOnline Aug 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: overall, can we make function naming more accurate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 8bf879e. Standardized the verbs from get to extract/collect.

"""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)

@classmethod

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do these helpers need class-level invocation or subclass override behavior? Origin code is just some private helpers.
if they are a must, can we make code more consistent?

@liramon2 liramon2 Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They don't need access to self, but then again other private helpers in the file don't access self. So to answer your question, they can just be private helpers. Done in 8bf879e

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 Down Expand Up @@ -305,6 +489,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 +567,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 +827,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 @@ -833,7 +1024,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