# -*- coding: utf-8 -*- """Extract attributes from AgentScope components for OpenTelemetry tracing.""" import inspect from typing import Any, Dict, TYPE_CHECKING from ...message import Msg, ToolCallBlock from ._attributes import ( SpanAttributes, OperationNameValues, ProviderNameValues, ) from ._converter import _convert_block_to_part from ._utils import _serialize_to_str from ...model import ChatResponse, ChatModelBase from ...event import ( ExternalExecutionResultEvent, UserConfirmResultEvent, ) if TYPE_CHECKING: from ...agent import Agent from ...tool import Toolkit, ToolChoice _CLASS_NAME_MAP = { "dashscope": ProviderNameValues.DASHSCOPE, "openai": ProviderNameValues.OPENAI, "anthropic": ProviderNameValues.ANTHROPIC, "gemini": ProviderNameValues.GCP_GEMINI, "ollama": ProviderNameValues.OLLAMA, "deepseek": ProviderNameValues.DEEPSEEK, "xai": ProviderNameValues.XAI, "moonshot": ProviderNameValues.MOONSHOT, } # Map base URL fragments to provider names for OpenAI-compatible APIs _BASE_URL_PROVIDER_MAP = [ ("api.openai.com", ProviderNameValues.OPENAI), ("dashscope", ProviderNameValues.DASHSCOPE), ("deepseek", ProviderNameValues.DEEPSEEK), ("moonshot", ProviderNameValues.MOONSHOT), ("generativelanguage.googleapis.com", ProviderNameValues.GCP_GEMINI), ("openai.azure.com", ProviderNameValues.AZURE_AI_OPENAI), ("amazonaws.com", ProviderNameValues.AWS_BEDROCK), ("api.x.ai", ProviderNameValues.XAI), ] def _get_common_attributes(session_id: str = "") -> Dict[str, str]: """Get common attributes for all spans. Args: session_id (`str`): The session ID to set as conversation ID. Returns: `Dict[str, str]`: Common span attributes including conversation ID. """ return { SpanAttributes.GEN_AI_CONVERSATION_ID: ( session_id if session_id else "[no_session_id]" ), } def _get_provider_name(instance: "ChatModelBase") -> str: """Get provider name from ChatModelBase instance. Maps ChatModelBase class names to provider names, with special handling for OpenAI-compatible APIs that may use different base URLs. This follows the implementation pattern from agentscope-java PR #73. Args: instance (`ChatModelBase`): The chat model instance to get the provider name for. Returns: `str`: Provider name (e.g., "openai", "dashscope", "anthropic") """ classname = instance.__class__.__name__ # For other model types, use direct mapping prefix_key = ( classname.removesuffix("ChatModel") .removesuffix("MultiAgentModel") .removesuffix("ResponseModel") .lower() ) # Special handling for OpenAI-compatible models — inspect base_url # from credential to distinguish the actual provider. if prefix_key == "openai": base_url = getattr(instance.credential, "base_url", None) if base_url: base_url = str(base_url) for url_fragment, provider_name in _BASE_URL_PROVIDER_MAP: if url_fragment in base_url: return provider_name return ProviderNameValues.OPENAI return _CLASS_NAME_MAP.get(prefix_key, "unknown") def _get_tool_definitions( tools: list[dict[str, Any]] | None, tool_choice: "ToolChoice | None", ) -> str | None: """Extract and serialize tool definitions for tracing. Converts AgentScope/OpenAI nested tool format to OpenTelemetry GenAI flat format for tracing. Args: tools (`list[dict[str, Any]] | None`, optional): List of tool definitions in OpenAI format with nested structure: ``[{"type": "function", "function": {...}}]`` tool_choice (`ToolChoice | None`, optional): Tool choice configuration with ``mode`` and optional ``tools`` fields. If mode is ``"none"``, returns None to indicate tools should not be traced. Returns: `str | None`: Serialized tool definitions in flat format: ``[{"type": "function", "name": ..., "parameters": ...}]`` or None if tools should not be traced (e.g., tools is None/empty or tool_choice is "none"). """ # No tools provided if tools is None or not isinstance(tools, list) or len(tools) == 0: return None # Tool choice is explicitly "none" (model should not use tools) if tool_choice is not None and tool_choice.mode == "none": return None try: # Convert nested format to flat format for OpenTelemetry GenAI # TODO: Currently only supports "function" type tools. If other tool # types are added in the future (e.g., "retrieval", "code_interpreter", # "browser"), this conversion logic needs to be updated to handle them. flat_tools = [] for tool in tools: if not isinstance(tool, dict) or "function" not in tool: continue func_def = tool["function"] flat_tool = { "type": tool.get("type", "function"), "name": func_def.get("name"), "description": func_def.get("description"), "parameters": func_def.get("parameters"), } # Remove None values flat_tool = {k: v for k, v in flat_tool.items() if v is not None} flat_tools.append(flat_tool) if flat_tools: return _serialize_to_str(flat_tools) return None except Exception: return None def _get_llm_request_attributes( instance: "ChatModelBase", kwargs: Dict[str, Any], ) -> Dict[str, Any]: """Get LLM request attributes for OpenTelemetry tracing. Extracts request parameters from LLM model calls into GenAI attributes. Args: instance (`ChatModelBase`): The chat model instance making the request. kwargs (`Dict[str, Any]`): Keyword arguments including generation parameters such as temperature, top_p, top_k, max_tokens, presence_penalty, frequency_penalty, stop_sequences, seed, tools, and tool_choice. Returns: `Dict[str, Any]`: OpenTelemetry GenAI attributes with mixed-type values (``str``, ``int``, ``float``, or ``list``), including operation name, provider name, model name, generation parameters (e.g. temperature, max_tokens, stop_sequences), and tool definitions. """ attributes = { # required attributes SpanAttributes.GEN_AI_OPERATION_NAME: OperationNameValues.CHAT, SpanAttributes.GEN_AI_PROVIDER_NAME: _get_provider_name(instance), # conditionally required attributes SpanAttributes.GEN_AI_REQUEST_MODEL: instance.model, # recommended attributes SpanAttributes.GEN_AI_REQUEST_TEMPERATURE: kwargs.get("temperature"), SpanAttributes.GEN_AI_REQUEST_TOP_P: kwargs.get("p") or kwargs.get("top_p"), SpanAttributes.GEN_AI_REQUEST_TOP_K: kwargs.get("top_k"), SpanAttributes.GEN_AI_REQUEST_MAX_TOKENS: kwargs.get("max_tokens"), SpanAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY: kwargs.get( "presence_penalty", ), SpanAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY: kwargs.get( "frequency_penalty", ), SpanAttributes.GEN_AI_REQUEST_STOP_SEQUENCES: kwargs.get( "stop_sequences", ), SpanAttributes.GEN_AI_REQUEST_SEED: kwargs.get("seed"), } # Extract tool definitions if provided tool_definitions = _get_tool_definitions( tools=kwargs.get("tools"), tool_choice=kwargs.get("tool_choice"), ) if tool_definitions: attributes[SpanAttributes.GEN_AI_TOOL_DEFINITIONS] = tool_definitions return {k: v for k, v in attributes.items() if v is not None} def _get_llm_span_name(attributes: Dict[str, str]) -> str: """Generate span name for LLM operations. Args: attributes (`Dict[str, str]`): LLM request attributes dictionary containing operation name and model name. Returns: `str`: Formatted span name in the format "{operation} {model}", e.g., "chat gpt-4" or "chat qwen-plus". """ return ( f"{attributes[SpanAttributes.GEN_AI_OPERATION_NAME]} " f"{attributes[SpanAttributes.GEN_AI_REQUEST_MODEL]}" ) def _get_llm_output_messages( chat_response: ChatResponse | None, ) -> list[dict[str, Any]]: """Extract and format LLM output messages for tracing. Converts ChatResponse objects to standardized message format compatible with OpenTelemetry GenAI specification. Args: chat_response (` ChatResponse | None`): Chat response object with content blocks. Should be a ChatResponse instance containing content blocks (text, tool_use, etc.). Returns: `list[dict[str, Any]]`: List containing a single formatted message dictionary with role, parts, and finish_reason. Returns the original response if it's not a ChatResponse instance, or an error message format if conversion fails. """ try: if not isinstance(chat_response, ChatResponse): return [ { "role": "assistant", "parts": [ { "type": "text", "content": str(chat_response), }, ], "finish_reason": "unknown", }, ] parts = [] finish_reason = "stop" # Default finish reason for block in chat_response.content: part = _convert_block_to_part(block) if part: parts.append(part) output_message = { "role": "assistant", "parts": parts, "finish_reason": finish_reason, } return [output_message] except Exception: return [ { "role": "assistant", "parts": [ { "type": "text", "content": "", }, ], "finish_reason": "error", }, ] def _get_llm_response_attributes( chat_response: ChatResponse | None, ) -> Dict[str, Any]: """Get LLM response attributes for OpenTelemetry tracing. Extracts response metadata and formats into GenAI attributes. Args: chat_response (`ChatResponse | None`): Chat response object with data and usage info. Should have attributes like id, usage (with input_tokens and output_tokens), and content blocks. Returns: `Dict[str, Any]`: OpenTelemetry GenAI response attributes including response ID, finish reasons, token usage (input/output tokens), and output messages. """ attributes = { SpanAttributes.GEN_AI_RESPONSE_ID: getattr( chat_response, "id", "unknown_id", ), # FIXME: finish reason should be capture in chat response SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS: '["stop"]', } if hasattr(chat_response, "usage") and chat_response.usage: usage = chat_response.usage attributes[ SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS ] = usage.input_tokens attributes[ SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS ] = usage.output_tokens cache_input = usage.cache_input_tokens if cache_input: attributes[ SpanAttributes.AGENTSCOPE_CACHE_INPUT_TOKENS ] = cache_input cache_creation = usage.cache_creation_input_tokens if cache_creation: attributes[ SpanAttributes.AGENTSCOPE_CACHE_CREATION_INPUT_TOKENS ] = cache_creation output_messages = _get_llm_output_messages(chat_response) if output_messages: attributes[SpanAttributes.GEN_AI_OUTPUT_MESSAGES] = _serialize_to_str( output_messages, ) return attributes def _get_agent_messages( msg: Msg | list[Msg], ) -> list[dict[str, Any]]: """Convert AgentScope message(s) to standardized parts format. Transforms Msg objects into OpenTelemetry GenAI format. Args: msg (`Msg | list[Msg]`): AgentScope message object or list of message objects with content blocks. Returns: `list[dict[str, Any]]`: List of formatted message dictionaries with role, parts, name, and finish_reason. """ try: if isinstance(msg, Msg): msg = [msg] formatted_msgs = [] for m in msg: parts = [] for block in m.get_content_blocks(): part = _convert_block_to_part(block) if part: parts.append(part) formatted_msg = { "role": m.role, "parts": parts, "name": m.name, "finish_reason": "stop", } formatted_msgs.append(formatted_msg) return formatted_msgs except Exception: # Fallback: try simple attribute access on the original object. # If msg was already converted to a list or lacks role/name, return # an empty list rather than raising a secondary exception. try: single = msg[0] if isinstance(msg, list) else msg return [ { "role": single.role, "parts": [ { "type": "text", "content": ( str(single.content) if single.content else "" ), }, ], "name": single.name, "finish_reason": "stop", }, ] except Exception: return [] def _get_agent_request_attributes( instance: "Agent", kwargs: Dict[str, Any], ) -> Dict[str, str]: """Get agent request attributes for OpenTelemetry tracing. Extracts agent metadata and input data into GenAI attributes. Args: instance (`Agent`): The agent instance making the request. kwargs (`Dict[str, Any]`): Keyword arguments passed to the agent's reply method. Returns: `Dict[str, str]`: OpenTelemetry GenAI attributes including operation name, agent ID, agent name, agent description, and input messages (if provided). """ attributes = { SpanAttributes.GEN_AI_OPERATION_NAME: ( OperationNameValues.INVOKE_AGENT ), SpanAttributes.GEN_AI_AGENT_NAME: instance.name, SpanAttributes.GEN_AI_AGENT_DESCRIPTION: inspect.getdoc( instance.__class__, ) or "No description available", } inputs = kwargs.get("inputs") if inputs is not None: if isinstance(inputs, (Msg, list)): input_messages = _get_agent_messages(inputs) attributes[ SpanAttributes.GEN_AI_INPUT_MESSAGES ] = _serialize_to_str(input_messages) elif isinstance(inputs, UserConfirmResultEvent): attributes[ SpanAttributes.AGENTSCOPE_INCOMING_EVENT_TYPE ] = "user_confirm_result" elif isinstance(inputs, ExternalExecutionResultEvent): attributes[ SpanAttributes.AGENTSCOPE_INCOMING_EVENT_TYPE ] = "external_execution_result" return attributes def _get_agent_span_name(attributes: Dict[str, str]) -> str: """Generate span name for agent operations. Args: attributes (`Dict[str, str]`): Agent request attributes dictionary containing operation name and agent name. Returns: `str`: Formatted span name in the format "{operation} {agent_name}", e.g., "invoke_agent MyAgent". """ return ( f"{attributes[SpanAttributes.GEN_AI_OPERATION_NAME]} " f"{attributes[SpanAttributes.GEN_AI_AGENT_NAME]}" ) def _get_agent_response_attributes( agent_response: Msg, ) -> Dict[str, str]: """Get agent response attributes for OpenTelemetry tracing. Args: agent_response (`Msg`): Response message returned by agent, containing content blocks. Returns: `Dict[str, str]`: OpenTelemetry GenAI response attributes including output messages. """ attributes = { SpanAttributes.GEN_AI_OUTPUT_MESSAGES: _serialize_to_str( _get_agent_messages(agent_response), ), } return attributes def _get_tool_request_attributes( instance: "Toolkit", tool_call: ToolCallBlock, ) -> Dict[str, str]: """Get tool request attributes for OpenTelemetry tracing. Extracts tool execution metadata into GenAI attributes. Args: instance (`Toolkit`): Toolkit instance with tool definitions. Used to extract tool description from the tool's JSON schema. tool_call (`ToolCallBlock`): Tool use block with call information including id, name, and input arguments. Returns: `Dict[str, str]`: OpenTelemetry GenAI tool attributes including operation name, tool call ID, tool name, tool description (if available), and tool call arguments. """ attributes = { SpanAttributes.GEN_AI_OPERATION_NAME: ( OperationNameValues.EXECUTE_TOOL ), } if tool_call: tool_name = tool_call.name attributes[SpanAttributes.GEN_AI_TOOL_CALL_ID] = tool_call.id attributes[SpanAttributes.GEN_AI_TOOL_NAME] = tool_name # tool_call.input is already a JSON string; pass it directly to avoid # double-encoding (e.g. '{"city": "Beijing"}' → '"{\\"city\\"...}"') attributes[SpanAttributes.GEN_AI_TOOL_CALL_ARGUMENTS] = tool_call.input if tool_name: registered = getattr(instance, "tools", {}).get(tool_name) if registered is not None: tool_obj = getattr(registered, "tool", None) description = getattr(tool_obj, "description", None) if description: attributes[ SpanAttributes.GEN_AI_TOOL_DESCRIPTION ] = description return attributes def _get_tool_span_name(attributes: Dict[str, str]) -> str: """Generate span name for tool operations. Args: attributes (`Dict[str, str]`): Tool request attributes dictionary containing operation name and tool name. Returns: `str`: Formatted span name in the format "{operation} {tool_name}", e.g., "execute_tool search". """ return ( f"{attributes[SpanAttributes.GEN_AI_OPERATION_NAME]} " f"{attributes[SpanAttributes.GEN_AI_TOOL_NAME]}" ) def _get_tool_response_attributes( tool_response: Any, ) -> Dict[str, str]: """Get tool response attributes for OpenTelemetry tracing. Args: tool_response (`Any`): Response object from tool execution. Can be any serializable object returned by the tool function. Returns: `Dict[str, str]`: OpenTelemetry GenAI response attributes including tool call result. """ attributes = { SpanAttributes.GEN_AI_TOOL_CALL_RESULT: _serialize_to_str( tool_response, ), } return attributes