Skip to content

Commit 5a0f047

Browse files
authored
Merge remote-tracking branch 'origin/nikhilc/messageStructureForAuto' into copilot/fix-tool-call-argument-schema
Co-authored-by: fpfp100 <126631706+fpfp100@users.noreply.github.com>
2 parents 1f6c296 + 01c32df commit 5a0f047

17 files changed

Lines changed: 2041 additions & 87 deletions

File tree

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/messages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class ToolCallRequestPart:
6666

6767
name: str
6868
id: str | None = None
69-
arguments: dict[str, object] | list[object] | None = None
69+
arguments: dict[str, object] | list[object] | str | None = None
7070
type: str = field(default="tool_call", init=False)
7171

7272

libraries/microsoft-agents-a365-observability-extensions-agentframework/docs/design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,5 @@ microsoft_agents_a365/observability/extensions/agentframework/
5555

5656
## Dependencies
5757

58-
- `agent-framework-azure-ai` - Microsoft Agents SDK
58+
- `agent-framework` - Microsoft Agents SDK
5959
- `microsoft-agents-a365-observability-core` - Core observability
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
logger.debug("Failed to parse input messages JSON: %s", messages_json[:200])
59+
return None
60+
61+
if not isinstance(raw, list):
62+
return None
63+
64+
chat_messages: list[ChatMessage] = []
65+
for msg in raw:
66+
if not isinstance(msg, dict):
67+
continue
68+
role = _map_role(msg.get("role"), MessageRole.USER)
69+
parts = _map_parts(msg)
70+
if parts:
71+
chat_messages.append(ChatMessage(role=role, parts=parts, name=msg.get("name")))
72+
73+
if not chat_messages:
74+
return None
75+
76+
return serialize_messages(InputMessages(messages=chat_messages))
77+
78+
79+
def map_output_messages(messages_json: str) -> str | None:
80+
"""Map a ``gen_ai.output.messages`` tag value to a serialized A365 JSON string.
81+
82+
Args:
83+
messages_json: The raw JSON string from the span attribute.
84+
85+
Returns:
86+
Serialized :class:`OutputMessages` JSON string, or ``None`` if the
87+
input is empty or cannot be parsed.
88+
"""
89+
try:
90+
raw = json.loads(messages_json)
91+
except (json.JSONDecodeError, TypeError):
92+
logger.debug("Failed to parse output messages JSON: %s", messages_json[:200])
93+
return None
94+
95+
if not isinstance(raw, list):
96+
return None
97+
98+
output_messages: list[OutputMessage] = []
99+
for msg in raw:
100+
if not isinstance(msg, dict):
101+
continue
102+
role = _map_role(msg.get("role"), MessageRole.ASSISTANT)
103+
parts = _map_parts(msg)
104+
finish_reason = msg.get("finish_reason")
105+
if parts:
106+
output_messages.append(
107+
OutputMessage(role=role, parts=parts, finish_reason=finish_reason)
108+
)
109+
110+
if not output_messages:
111+
return None
112+
113+
return serialize_messages(OutputMessages(messages=output_messages))
114+
115+
116+
# ---------------------------------------------------------------------------
117+
# Internal helpers
118+
# ---------------------------------------------------------------------------
119+
120+
121+
def _map_role(role: str | None, default: MessageRole) -> MessageRole:
122+
"""Map a raw role string to a :class:`MessageRole` enum."""
123+
if not role:
124+
return default
125+
return _ROLE_MAP.get(role.lower(), default)
126+
127+
128+
def _map_parts(msg: dict[str, Any]) -> list[MessagePart]:
129+
"""Map all parts in a raw message dict."""
130+
parts_data = msg.get("parts", [])
131+
if not isinstance(parts_data, list):
132+
return []
133+
mapped = [_map_single_part(p) for p in parts_data if isinstance(p, dict)]
134+
return [p for p in mapped if p is not None]
135+
136+
137+
def _map_single_part(part: dict[str, Any]) -> MessagePart | None:
138+
"""Map a single raw part dict to the appropriate A365 message part."""
139+
part_type = part.get("type", "")
140+
141+
if part_type == "text":
142+
content = part.get("content", "")
143+
return TextPart(content=content) if content else None
144+
145+
if part_type == "reasoning":
146+
content = part.get("content", "")
147+
return ReasoningPart(content=content) if content else None
148+
149+
if part_type == "tool_call":
150+
name = part.get("name")
151+
if not name:
152+
return None
153+
return ToolCallRequestPart(
154+
name=name,
155+
id=part.get("id"),
156+
arguments=part.get("arguments"),
157+
)
158+
159+
if part_type == "tool_call_response":
160+
return ToolCallResponsePart(
161+
id=part.get("id"),
162+
response=part.get("response"),
163+
)
164+
165+
if part_type == "blob":
166+
modality = part.get("modality", "")
167+
content = part.get("content", "")
168+
if not modality or not content:
169+
return None
170+
return BlobPart(modality=modality, content=content, mime_type=part.get("mime_type"))
171+
172+
if part_type == "file":
173+
modality = part.get("modality", "")
174+
file_id = part.get("file_id", "")
175+
if not modality or not file_id:
176+
return None
177+
return FilePart(modality=modality, file_id=file_id, mime_type=part.get("mime_type"))
178+
179+
if part_type == "uri":
180+
modality = part.get("modality", "")
181+
uri = part.get("uri", "")
182+
if not modality or not uri:
183+
return None
184+
return UriPart(modality=modality, uri=uri, mime_type=part.get("mime_type"))
185+
186+
# Fallback: GenericPart for unknown/future part types
187+
data = {k: v for k, v in part.items() if k != "type"}
188+
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 >= 1.0.0",)
2727

2828

2929
class AgentFrameworkInstrumentor(BaseInstrumentor):

0 commit comments

Comments
 (0)