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: 1 addition & 1 deletion agentops/instrumentation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ class InstrumentorConfig(TypedDict):
"agents": {
"module_name": "agentops.instrumentation.openai_agents",
"class_name": "OpenAIAgentsInstrumentor",
"min_version": "0.1.0",
"min_version": "0.0.1",
},
}

Expand Down
2 changes: 1 addition & 1 deletion agentops/instrumentation/openai/attributes/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
SpanAttributes.LLM_RESPONSE_ID: "id",
SpanAttributes.LLM_REQUEST_MODEL: "model",
SpanAttributes.LLM_RESPONSE_MODEL: "model",
SpanAttributes.LLM_PROMPTS: "instructions",
SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS: "instructions",
SpanAttributes.LLM_REQUEST_MAX_TOKENS: "max_output_tokens",
SpanAttributes.LLM_REQUEST_TEMPERATURE: "temperature",
SpanAttributes.LLM_REQUEST_TOP_P: "top_p",
Expand Down
252 changes: 236 additions & 16 deletions agentops/instrumentation/openai_agents/attributes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@
for extracting and formatting attributes according to OpenTelemetry semantic conventions.
"""

from typing import Any
from typing import Any, List, Dict, Optional
from agentops.logging import logger
from agentops.semconv import AgentAttributes, WorkflowAttributes, SpanAttributes, InstrumentationAttributes
from agentops.semconv import (
AgentAttributes,
WorkflowAttributes,
SpanAttributes,
InstrumentationAttributes,
ToolAttributes,
AgentOpsSpanKindValues,
ToolStatus,
)
from agentops.helpers import safe_serialize # Import safe_serialize

from agentops.instrumentation.common import AttributeMap, _extract_attributes_from_mapping
from agentops.instrumentation.common.attributes import get_common_attributes
from agentops.instrumentation.common.objects import get_uploaded_object_attributes
from agentops.instrumentation.openai.attributes.response import get_response_response_attributes
from agentops.instrumentation.openai_agents import LIBRARY_NAME, LIBRARY_VERSION

from agentops.instrumentation.openai_agents.attributes.model import (
get_model_attributes,
get_model_config_attributes,
Expand All @@ -33,9 +43,10 @@

# Attribute mapping for FunctionSpanData
FUNCTION_SPAN_ATTRIBUTES: AttributeMap = {
AgentAttributes.AGENT_NAME: "name",
WorkflowAttributes.WORKFLOW_INPUT: "input",
WorkflowAttributes.FINAL_OUTPUT: "output",
ToolAttributes.TOOL_NAME: "name",
ToolAttributes.TOOL_PARAMETERS: "input",
ToolAttributes.TOOL_RESULT: "output",
# AgentAttributes.AGENT_NAME: "name",
AgentAttributes.FROM_AGENT: "from_agent",
}

Expand All @@ -55,7 +66,9 @@

# Attribute mapping for ResponseSpanData
RESPONSE_SPAN_ATTRIBUTES: AttributeMap = {
WorkflowAttributes.WORKFLOW_INPUT: "input",
# Don't map input here as it causes double serialization
# We handle prompts manually in get_response_span_attributes
SpanAttributes.LLM_RESPONSE_MODEL: "model",
}


Expand All @@ -80,6 +93,72 @@
}


def _get_llm_messages_attributes(messages: Optional[List[Dict]], attribute_base: str) -> AttributeMap:
"""
Extracts attributes from a list of message dictionaries (e.g., prompts or completions).
Uses the attribute_base to format the specific attribute keys.
"""
attributes: AttributeMap = {}
if not messages:
return attributes

Check warning on line 103 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L103

Added line #L103 was not covered by tests
if not isinstance(messages, list):
logger.warning(

Check warning on line 105 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L105

Added line #L105 was not covered by tests
f"[_get_llm_messages_attributes] Expected a list of messages for base '{attribute_base}', got {type(messages)}. Value: {safe_serialize(messages)}. Returning empty."
)
return attributes

Check warning on line 108 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L108

Added line #L108 was not covered by tests

for i, msg_dict in enumerate(messages):
if isinstance(msg_dict, dict):
role = msg_dict.get("role")
content = msg_dict.get("content")
name = msg_dict.get("name")
tool_calls = msg_dict.get("tool_calls")
tool_call_id = msg_dict.get("tool_call_id")

# Common role and content
if role:
attributes[f"{attribute_base}.{i}.role"] = str(role)
if content is not None:
attributes[f"{attribute_base}.{i}.content"] = safe_serialize(content)

# Optional name for some roles
if name:
attributes[f"{attribute_base}.{i}.name"] = str(name)

Check warning on line 126 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L126

Added line #L126 was not covered by tests

# Tool calls (specific to assistant messages)
if tool_calls and isinstance(tool_calls, list):
for tc_idx, tc_dict in enumerate(tool_calls):
if isinstance(tc_dict, dict):
tc_id = tc_dict.get("id")
tc_type = tc_dict.get("type")
tc_function_data = tc_dict.get("function")

Check warning on line 134 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L130-L134

Added lines #L130 - L134 were not covered by tests

if tc_function_data and isinstance(tc_function_data, dict):
tc_func_name = tc_function_data.get("name")
tc_func_args = tc_function_data.get("arguments")

Check warning on line 138 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L136-L138

Added lines #L136 - L138 were not covered by tests

base_tool_call_key_formatted = f"{attribute_base}.{i}.tool_calls.{tc_idx}"
if tc_id:
attributes[f"{base_tool_call_key_formatted}.id"] = str(tc_id)
if tc_type:
attributes[f"{base_tool_call_key_formatted}.type"] = str(tc_type)
if tc_func_name:
attributes[f"{base_tool_call_key_formatted}.function.name"] = str(tc_func_name)
if tc_func_args is not None:
attributes[f"{base_tool_call_key_formatted}.function.arguments"] = safe_serialize(

Check warning on line 148 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L140-L148

Added lines #L140 - L148 were not covered by tests
tc_func_args
)

# Tool call ID (specific to tool_call_output messages)
if tool_call_id:
attributes[f"{attribute_base}.{i}.tool_call_id"] = str(tool_call_id)

Check warning on line 154 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L154

Added line #L154 was not covered by tests
else:
# If a message is not a dict, serialize its representation
attributes[f"{attribute_base}.{i}.content"] = safe_serialize(msg_dict)

Check warning on line 157 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L157

Added line #L157 was not covered by tests

return attributes


def get_common_instrumentation_attributes() -> AttributeMap:
"""Get common instrumentation attributes for the OpenAI Agents instrumentation.
Expand Down Expand Up @@ -109,9 +188,22 @@
Returns:
Dictionary of attributes for agent span
"""
attributes = _extract_attributes_from_mapping(span_data, AGENT_SPAN_ATTRIBUTES)
attributes = {}
attributes.update(get_common_attributes())

attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.AGENT.value

# Get agent name directly from span_data
if hasattr(span_data, "name") and span_data.name:
attributes[AgentAttributes.AGENT_NAME] = str(span_data.name)

# Get handoffs directly from span_data
if hasattr(span_data, "handoffs") and span_data.handoffs:
attributes[AgentAttributes.HANDOFFS] = safe_serialize(span_data.handoffs)

if hasattr(span_data, "tools") and span_data.tools:
attributes[AgentAttributes.AGENT_TOOLS] = safe_serialize([str(getattr(t, "name", t)) for t in span_data.tools])

return attributes


Expand All @@ -128,6 +220,20 @@
"""
attributes = _extract_attributes_from_mapping(span_data, FUNCTION_SPAN_ATTRIBUTES)
attributes.update(get_common_attributes())
attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.TOOL.value

# Determine tool status based on presence of error
if hasattr(span_data, "error") and span_data.error:
attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.FAILED.value
else:
if hasattr(span_data, "output") and span_data.output is not None:
attributes[ToolAttributes.TOOL_STATUS] = ToolStatus.SUCCEEDED.value
else:
# Status will be set by exporter based on span lifecycle
pass

if hasattr(span_data, "from_agent") and span_data.from_agent:
attributes[f"{AgentAttributes.AGENT}.calling_tool.name"] = str(span_data.from_agent)

return attributes

Expand All @@ -149,6 +255,66 @@
return attributes


def _extract_text_from_content(content: Any) -> Optional[str]:
"""Extract text from various content formats used in the Responses API.
Args:
content: Content in various formats (str, dict, list)
Returns:
Extracted text or None if no text found
"""
if isinstance(content, str):
return content

Check warning on line 268 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L267-L268

Added lines #L267 - L268 were not covered by tests

if isinstance(content, dict):

Check warning on line 270 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L270

Added line #L270 was not covered by tests
# Direct text field
if "text" in content:
return content["text"]

Check warning on line 273 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L272-L273

Added lines #L272 - L273 were not covered by tests
# Output text type
if content.get("type") == "output_text":
return content.get("text", "")

Check warning on line 276 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L275-L276

Added lines #L275 - L276 were not covered by tests

if isinstance(content, list):
text_parts = []
for item in content:
extracted = _extract_text_from_content(item)
if extracted:
text_parts.append(extracted)
return " ".join(text_parts) if text_parts else None

Check warning on line 284 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L278-L284

Added lines #L278 - L284 were not covered by tests

return None

Check warning on line 286 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L286

Added line #L286 was not covered by tests


def _build_prompt_messages_from_input(input_data: Any) -> List[Dict[str, Any]]:
"""Build prompt messages from various input formats.
Args:
input_data: Input data from span_data.input
Returns:
List of message dictionaries with role and content
"""
messages = []

if isinstance(input_data, str):
# Single string input - assume it's a user message
messages.append({"role": "user", "content": input_data})

elif isinstance(input_data, list):
for msg in input_data:
if isinstance(msg, dict):
role = msg.get("role")
content = msg.get("content")

Check warning on line 308 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L304-L308

Added lines #L304 - L308 were not covered by tests

if role and content is not None:
extracted_text = _extract_text_from_content(content)
if extracted_text:
messages.append({"role": role, "content": extracted_text})

Check warning on line 313 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L310-L313

Added lines #L310 - L313 were not covered by tests

return messages


def get_response_span_attributes(span_data: Any) -> AttributeMap:
"""Extract attributes from a ResponseSpanData object with full LLM response processing.
Expand All @@ -170,8 +336,43 @@
attributes = _extract_attributes_from_mapping(span_data, RESPONSE_SPAN_ATTRIBUTES)
attributes.update(get_common_attributes())

# Process response attributes first to get all response data including instructions
if span_data.response:
attributes.update(get_response_response_attributes(span_data.response))
response_attrs = get_response_response_attributes(span_data.response)

# Extract system prompt if present
system_prompt = response_attrs.get(SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS)

prompt_messages = []
# Add system prompt as first message if available
if system_prompt:
prompt_messages.append({"role": "system", "content": system_prompt})

Check warning on line 349 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L349

Added line #L349 was not covered by tests
# Remove from response attrs to avoid duplication
response_attrs.pop(SpanAttributes.LLM_OPENAI_RESPONSE_INSTRUCTIONS, None)

Check warning on line 351 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L351

Added line #L351 was not covered by tests

# Add conversation history from input
if hasattr(span_data, "input") and span_data.input:
prompt_messages.extend(_build_prompt_messages_from_input(span_data.input))

# Format prompts using existing function
if prompt_messages:
attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt"))

# Remove any prompt-related attributes that might have been set by response processing
response_attrs = {
k: v for k, v in response_attrs.items() if not k.startswith("gen_ai.prompt") and k != "gen_ai.request.tools"
}

# Add remaining response attributes
attributes.update(response_attrs)
else:
# No response object, just process input as prompts
if hasattr(span_data, "input") and span_data.input:
prompt_messages = _build_prompt_messages_from_input(span_data.input)
if prompt_messages:
attributes.update(_get_llm_messages_attributes(prompt_messages, "gen_ai.prompt"))

Check warning on line 373 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L370-L373

Added lines #L370 - L373 were not covered by tests

attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value

return attributes

Expand All @@ -181,12 +382,6 @@
Generations are requests made to the `openai.completions` endpoint.
# TODO this has not been extensively tested yet as there is a flag that needs ot be set to use the
# completions API with the Agents SDK.
# We can enable chat.completions API by calling:
# `from agents import set_default_openai_api`
# `set_default_openai_api("chat_completions")`
Args:
span_data: The GenerationSpanData object
Expand All @@ -196,17 +391,42 @@
attributes = _extract_attributes_from_mapping(span_data, GENERATION_SPAN_ATTRIBUTES)
attributes.update(get_common_attributes())

if SpanAttributes.LLM_PROMPTS in attributes:
raw_prompt_input = attributes.pop(SpanAttributes.LLM_PROMPTS)
formatted_prompt_for_llm = []
if isinstance(raw_prompt_input, str):
formatted_prompt_for_llm.append({"role": "user", "content": raw_prompt_input})
elif isinstance(raw_prompt_input, list):
temp_formatted_list = []
all_strings_or_dicts = True
for item in raw_prompt_input:
if isinstance(item, str):
temp_formatted_list.append({"role": "user", "content": item})
elif isinstance(item, dict):
temp_formatted_list.append(item)

Check warning on line 406 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L399-L406

Added lines #L399 - L406 were not covered by tests
else:
all_strings_or_dicts = False
break
if all_strings_or_dicts:
formatted_prompt_for_llm = temp_formatted_list

Check warning on line 411 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L408-L411

Added lines #L408 - L411 were not covered by tests
else:
logger.warning(

Check warning on line 413 in agentops/instrumentation/openai_agents/attributes/common.py

View check run for this annotation

Codecov / codecov/patch

agentops/instrumentation/openai_agents/attributes/common.py#L413

Added line #L413 was not covered by tests
f"[get_generation_span_attributes] span_data.input was a list with mixed/unexpected content: {safe_serialize(raw_prompt_input)}"
)

if formatted_prompt_for_llm:
attributes.update(_get_llm_messages_attributes(formatted_prompt_for_llm, "gen_ai.prompt"))

if span_data.model:
attributes.update(get_model_attributes(span_data.model))

# Process output for GenerationSpanData if available
if span_data.output:
attributes.update(get_generation_output_attributes(span_data.output))

# Add model config attributes if present
if span_data.model_config:
attributes.update(get_model_config_attributes(span_data.model_config))

attributes[SpanAttributes.AGENTOPS_SPAN_KIND] = AgentOpsSpanKindValues.LLM.value
return attributes


Expand Down
Loading
Loading