Skip to content

Commit bc06877

Browse files
support message format for agent framework
1 parent 8b94a1d commit bc06877

6 files changed

Lines changed: 481 additions & 20 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Maps Agent Framework span tag messages to A365 versioned message format.
5+
6+
Agent Framework sets ``gen_ai.input.messages`` / ``gen_ai.output.messages`` as span
7+
tags containing JSON arrays of ``{role, parts[{type, content}], finish_reason?}``.
8+
This mapper converts them to :class:`InputMessages` / :class:`OutputMessages`.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import logging
15+
from typing import Any
16+
17+
from microsoft_agents_a365.observability.core.message_utils import serialize_messages
18+
from microsoft_agents_a365.observability.core.models.messages import (
19+
BlobPart,
20+
ChatMessage,
21+
FilePart,
22+
GenericPart,
23+
InputMessages,
24+
MessagePart,
25+
MessageRole,
26+
OutputMessage,
27+
OutputMessages,
28+
ReasoningPart,
29+
TextPart,
30+
ToolCallRequestPart,
31+
ToolCallResponsePart,
32+
UriPart,
33+
)
34+
35+
logger = logging.getLogger(__name__)
36+
37+
_ROLE_MAP: dict[str, MessageRole] = {
38+
"system": MessageRole.SYSTEM,
39+
"user": MessageRole.USER,
40+
"assistant": MessageRole.ASSISTANT,
41+
"tool": MessageRole.TOOL,
42+
}
43+
44+
45+
def map_input_messages(messages_json: str) -> str | None:
46+
"""Map a ``gen_ai.input.messages`` tag value to a serialized A365 JSON string.
47+
48+
Args:
49+
messages_json: The raw JSON string from the span attribute.
50+
51+
Returns:
52+
Serialized :class:`InputMessages` JSON string, or ``None`` if the
53+
input is empty or cannot be parsed.
54+
"""
55+
try:
56+
raw = json.loads(messages_json)
57+
except (json.JSONDecodeError, TypeError):
58+
return None
59+
60+
if not isinstance(raw, list):
61+
return None
62+
63+
chat_messages: list[ChatMessage] = []
64+
for msg in raw:
65+
if not isinstance(msg, dict):
66+
continue
67+
role = _map_role(msg.get("role"), MessageRole.USER)
68+
parts = _map_parts(msg)
69+
if parts:
70+
chat_messages.append(ChatMessage(role=role, parts=parts, name=msg.get("name")))
71+
72+
if not chat_messages:
73+
return None
74+
75+
return serialize_messages(InputMessages(messages=chat_messages))
76+
77+
78+
def map_output_messages(messages_json: str) -> str | None:
79+
"""Map a ``gen_ai.output.messages`` tag value to a serialized A365 JSON string.
80+
81+
Args:
82+
messages_json: The raw JSON string from the span attribute.
83+
84+
Returns:
85+
Serialized :class:`OutputMessages` JSON string, or ``None`` if the
86+
input is empty or cannot be parsed.
87+
"""
88+
try:
89+
raw = json.loads(messages_json)
90+
except (json.JSONDecodeError, TypeError):
91+
return None
92+
93+
if not isinstance(raw, list):
94+
return None
95+
96+
output_messages: list[OutputMessage] = []
97+
for msg in raw:
98+
if not isinstance(msg, dict):
99+
continue
100+
role = _map_role(msg.get("role"), MessageRole.ASSISTANT)
101+
parts = _map_parts(msg)
102+
finish_reason = msg.get("finish_reason")
103+
if parts:
104+
output_messages.append(
105+
OutputMessage(role=role, parts=parts, finish_reason=finish_reason)
106+
)
107+
108+
if not output_messages:
109+
return None
110+
111+
return serialize_messages(OutputMessages(messages=output_messages))
112+
113+
114+
# ---------------------------------------------------------------------------
115+
# Internal helpers
116+
# ---------------------------------------------------------------------------
117+
118+
119+
def _map_role(role: str | None, default: MessageRole) -> MessageRole:
120+
"""Map a raw role string to a :class:`MessageRole` enum."""
121+
if not role:
122+
return default
123+
return _ROLE_MAP.get(role.lower(), default)
124+
125+
126+
def _map_parts(msg: dict[str, Any]) -> list[MessagePart]:
127+
"""Map all parts in a raw message dict."""
128+
parts_data = msg.get("parts", [])
129+
if not isinstance(parts_data, list):
130+
return []
131+
mapped = [_map_single_part(p) for p in parts_data if isinstance(p, dict)]
132+
return [p for p in mapped if p is not None]
133+
134+
135+
def _map_single_part(part: dict[str, Any]) -> MessagePart | None:
136+
"""Map a single raw part dict to the appropriate A365 message part."""
137+
part_type = part.get("type", "")
138+
139+
if part_type == "text":
140+
content = part.get("content", "")
141+
return TextPart(content=content) if content else None
142+
143+
if part_type == "reasoning":
144+
content = part.get("content", "")
145+
return ReasoningPart(content=content) if content else None
146+
147+
if part_type == "tool_call":
148+
name = part.get("name")
149+
if not name:
150+
return None
151+
return ToolCallRequestPart(
152+
name=name,
153+
id=part.get("id"),
154+
arguments=part.get("arguments"),
155+
)
156+
157+
if part_type == "tool_call_response":
158+
return ToolCallResponsePart(
159+
id=part.get("id"),
160+
response=part.get("response"),
161+
)
162+
163+
if part_type == "blob":
164+
modality = part.get("modality", "")
165+
content = part.get("content", "")
166+
if not modality or not content:
167+
return None
168+
return BlobPart(modality=modality, content=content, mime_type=part.get("mime_type"))
169+
170+
if part_type == "file":
171+
modality = part.get("modality", "")
172+
file_id = part.get("file_id", "")
173+
if not modality or not file_id:
174+
return None
175+
return FilePart(modality=modality, file_id=file_id, mime_type=part.get("mime_type"))
176+
177+
if part_type == "uri":
178+
modality = part.get("modality", "")
179+
uri = part.get("uri", "")
180+
if not modality or not uri:
181+
return None
182+
return UriPart(modality=modality, uri=uri, mime_type=part.get("mime_type"))
183+
184+
# Fallback: GenericPart for unknown/future part types
185+
data = {k: v for k, v in part.items() if k != "type"}
186+
return GenericPart(type=part_type, data=data) if part_type else None

libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_enricher.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
# Licensed under the MIT License.
33

44
from microsoft_agents_a365.observability.core.constants import (
5+
CHAT_OPERATION_NAME,
56
EXECUTE_TOOL_OPERATION_NAME,
67
GEN_AI_INPUT_MESSAGES_KEY,
8+
GEN_AI_OPERATION_NAME_KEY,
79
GEN_AI_OUTPUT_MESSAGES_KEY,
810
GEN_AI_TOOL_ARGS_KEY,
911
GEN_AI_TOOL_CALL_RESULT_KEY,
@@ -12,33 +14,50 @@
1214
from microsoft_agents_a365.observability.core.exporters.enriched_span import EnrichedReadableSpan
1315
from opentelemetry.sdk.trace import ReadableSpan
1416

15-
from .utils import extract_input_content, extract_output_content
17+
from .message_mapper import map_input_messages, map_output_messages
1618

1719
# Agent Framework specific attribute keys
1820
AF_TOOL_CALL_ARGUMENTS_KEY = "gen_ai.tool.call.arguments"
1921
AF_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result"
2022

23+
_MESSAGE_OPERATIONS = {INVOKE_AGENT_OPERATION_NAME, CHAT_OPERATION_NAME}
24+
2125

2226
def enrich_agent_framework_span(span: ReadableSpan) -> ReadableSpan:
27+
"""Enricher function for Agent Framework spans.
28+
29+
For ``invoke_agent`` and ``chat`` operations, maps the raw
30+
``gen_ai.input.messages`` / ``gen_ai.output.messages`` JSON arrays
31+
to the A365 versioned format.
32+
33+
For ``execute_tool`` operations, maps Agent Framework tool attribute
34+
keys to the A365 standard keys.
2335
"""
24-
Enricher function for Agent Framework spans.
25-
"""
26-
extra_attributes = {}
36+
extra_attributes: dict[str, str] = {}
2737
attributes = span.attributes or {}
38+
operation = attributes.get(GEN_AI_OPERATION_NAME_KEY, "")
39+
40+
is_message_span = operation in _MESSAGE_OPERATIONS or span.name.startswith(
41+
INVOKE_AGENT_OPERATION_NAME
42+
)
43+
is_tool_span = operation == EXECUTE_TOOL_OPERATION_NAME or span.name.startswith(
44+
EXECUTE_TOOL_OPERATION_NAME
45+
)
2846

29-
# Only extract content for invoke_agent spans
30-
if span.name.startswith(INVOKE_AGENT_OPERATION_NAME):
31-
# Extract all text content from input messages
47+
if is_message_span:
3248
input_messages = attributes.get(GEN_AI_INPUT_MESSAGES_KEY)
3349
if input_messages:
34-
extra_attributes[GEN_AI_INPUT_MESSAGES_KEY] = extract_input_content(input_messages)
50+
mapped = map_input_messages(input_messages)
51+
if mapped is not None:
52+
extra_attributes[GEN_AI_INPUT_MESSAGES_KEY] = mapped
3553

3654
output_messages = attributes.get(GEN_AI_OUTPUT_MESSAGES_KEY)
3755
if output_messages:
38-
extra_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] = extract_output_content(output_messages)
56+
mapped = map_output_messages(output_messages)
57+
if mapped is not None:
58+
extra_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] = mapped
3959

40-
# Map tool attributes for execute_tool spans
41-
elif span.name.startswith(EXECUTE_TOOL_OPERATION_NAME):
60+
elif is_tool_span:
4261
if AF_TOOL_CALL_ARGUMENTS_KEY in attributes:
4362
extra_attributes[GEN_AI_TOOL_ARGS_KEY] = attributes[AF_TOOL_CALL_ARGUMENTS_KEY]
4463

libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_processor.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
class AgentFrameworkSpanProcessor(SpanProcessor):
88
"""SpanProcessor for Agent Framework.
99
10-
Note: The span processing logic was removed as GEN_AI_EVENT_CONTENT is no longer used.
11-
This processor is kept for interface compatibility.
10+
Attribute mutation happens in the enricher (via :class:`EnrichedReadableSpan`)
11+
because OTel Python ``ReadableSpan`` is immutable after ``on_end``.
12+
The enricher is invoked at export time by the ``EnrichingSpanProcessor``.
1213
"""
1314

14-
TOOL_CALL_RESULT_TAG = "gen_ai.tool.call.result"
15-
1615
def __init__(self, service_name: str | None = None):
1716
self.service_name = service_name
1817
super().__init__()
@@ -22,5 +21,9 @@ def on_start(self, span, parent_context):
2221
pass
2322

2423
def on_end(self, span):
25-
"""Called when a span ends. Intentionally a no-op."""
24+
"""Called when a span ends. Intentionally a no-op.
25+
26+
Message mapping is handled by the span enricher at export time
27+
since ReadableSpan is immutable in the Python OTel SDK.
28+
"""
2629
pass

libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/trace_instrumentor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# -----------------------------
2424
# 3) The Instrumentor class
2525
# -----------------------------
26-
_instruments = ("agent-framework-azure-ai >= 1.0.0",)
26+
_instruments = ("agent-framework-azure-ai >= 1.0.0rc1",)
2727

2828

2929
class AgentFrameworkInstrumentor(BaseInstrumentor):

0 commit comments

Comments
 (0)