Skip to content

Commit 3d24c26

Browse files
support message format for langchain
1 parent bc06877 commit 3d24c26

7 files changed

Lines changed: 698 additions & 65 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Maps LangChain messages to A365 versioned message format.
5+
6+
LangChain provides ``BaseMessage`` objects (``HumanMessage``, ``AIMessage``,
7+
``SystemMessage``, ``ToolMessage``) in ``run.inputs["messages"]`` and
8+
``run.outputs["generations"]``. This mapper converts them to the A365
9+
versioned format (``InputMessages`` / ``OutputMessages``).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
from collections.abc import Iterable, Mapping
16+
from typing import Any
17+
18+
from langchain_core.messages import BaseMessage
19+
20+
from microsoft_agents_a365.observability.core.message_utils import serialize_messages
21+
from microsoft_agents_a365.observability.core.models.messages import (
22+
ChatMessage,
23+
InputMessages,
24+
MessagePart,
25+
MessageRole,
26+
OutputMessage,
27+
OutputMessages,
28+
TextPart,
29+
ToolCallRequestPart,
30+
ToolCallResponsePart,
31+
)
32+
33+
logger = logging.getLogger(__name__)
34+
35+
_ROLE_MAP: dict[str, MessageRole] = {
36+
"human": MessageRole.USER,
37+
"user": MessageRole.USER,
38+
"ai": MessageRole.ASSISTANT,
39+
"assistant": MessageRole.ASSISTANT,
40+
"system": MessageRole.SYSTEM,
41+
"tool": MessageRole.TOOL,
42+
}
43+
44+
45+
def map_input_messages(inputs: Mapping[str, Any] | None) -> str | None:
46+
"""Map LangChain input messages to a serialized A365 InputMessages JSON string.
47+
48+
Args:
49+
inputs: The ``run.inputs`` mapping from a LangChain run.
50+
51+
Returns:
52+
Serialized InputMessages JSON string, or None if no messages found.
53+
"""
54+
if not inputs or not isinstance(inputs, Mapping):
55+
return None
56+
57+
multiple_messages = inputs.get("messages")
58+
if not multiple_messages or not isinstance(multiple_messages, Iterable):
59+
return None
60+
61+
first_messages = next(iter(multiple_messages), None)
62+
if not first_messages:
63+
return None
64+
65+
# Normalize to a list
66+
if isinstance(first_messages, BaseMessage):
67+
first_messages = [first_messages]
68+
elif not isinstance(first_messages, list):
69+
return None
70+
71+
chat_messages: list[ChatMessage] = []
72+
for msg in first_messages:
73+
mapped = _map_base_message(msg)
74+
if mapped is not None:
75+
chat_messages.append(mapped)
76+
77+
if not chat_messages:
78+
return None
79+
80+
return serialize_messages(InputMessages(messages=chat_messages))
81+
82+
83+
def map_output_messages(outputs: Mapping[str, Any] | None) -> str | None:
84+
"""Map LangChain output messages to a serialized A365 OutputMessages JSON string.
85+
86+
Args:
87+
outputs: The ``run.outputs`` mapping from a LangChain run.
88+
89+
Returns:
90+
Serialized OutputMessages JSON string, or None if no messages found.
91+
"""
92+
if not outputs or not isinstance(outputs, Mapping):
93+
return None
94+
95+
multiple_generations = outputs.get("generations")
96+
if not multiple_generations or not isinstance(multiple_generations, Iterable):
97+
return None
98+
99+
first_generations = next(iter(multiple_generations), None)
100+
if not first_generations or not isinstance(first_generations, Iterable):
101+
return None
102+
103+
output_messages: list[OutputMessage] = []
104+
for generation in first_generations:
105+
if not isinstance(generation, Mapping):
106+
continue
107+
message_data = generation.get("message")
108+
if message_data is None:
109+
continue
110+
111+
mapped = _map_to_output_message(message_data, generation)
112+
if mapped is not None:
113+
output_messages.append(mapped)
114+
115+
if not output_messages:
116+
return None
117+
118+
return serialize_messages(OutputMessages(messages=output_messages))
119+
120+
121+
# ---------------------------------------------------------------------------
122+
# Internal helpers
123+
# ---------------------------------------------------------------------------
124+
125+
126+
def _map_role(
127+
msg: BaseMessage | Mapping[str, Any], default: MessageRole = MessageRole.USER
128+
) -> MessageRole:
129+
"""Extract the role from a LangChain message."""
130+
if isinstance(msg, BaseMessage):
131+
role_str = msg.type
132+
elif isinstance(msg, Mapping):
133+
# Direct type field (e.g. "human", "ai", "system", "tool")
134+
role_str = msg.get("type", "")
135+
# LC serialization uses "constructor" as type with role in kwargs
136+
if role_str == "constructor":
137+
kwargs = msg.get("kwargs", {})
138+
role_str = kwargs.get("type", "") if isinstance(kwargs, Mapping) else ""
139+
# Also check "role" field
140+
if not role_str or role_str not in _ROLE_MAP:
141+
role_str = msg.get("role", role_str)
142+
else:
143+
role_str = ""
144+
return _ROLE_MAP.get(role_str.lower(), default)
145+
146+
147+
def _extract_parts(msg: BaseMessage | Mapping[str, Any]) -> list[MessagePart]:
148+
"""Extract message parts from a LangChain message."""
149+
parts: list[MessagePart] = []
150+
151+
# Extract content and tool_calls
152+
if isinstance(msg, BaseMessage):
153+
content = msg.content
154+
tool_calls = getattr(msg, "tool_calls", None)
155+
msg_type = msg.type
156+
tool_call_id = getattr(msg, "tool_call_id", None)
157+
elif isinstance(msg, Mapping):
158+
# Handle LC serialization: {"type": "constructor", "kwargs": {content, type, ...}}
159+
kwargs = msg.get("kwargs", {}) if msg.get("type") == "constructor" else msg
160+
if not isinstance(kwargs, Mapping):
161+
kwargs = msg
162+
content = kwargs.get("content", "") or msg.get("content", "")
163+
tool_calls = kwargs.get("tool_calls") or msg.get("tool_calls")
164+
msg_type = kwargs.get("type", "") or msg.get("type", "")
165+
tool_call_id = kwargs.get("tool_call_id") or msg.get("tool_call_id")
166+
else:
167+
return parts
168+
169+
# Tool response (from ToolMessage) — handle before text to avoid double-counting
170+
if msg_type == "tool":
171+
response = content if isinstance(content, str) else str(content) if content else ""
172+
if response or tool_call_id:
173+
parts.append(ToolCallResponsePart(id=tool_call_id, response=response))
174+
return parts
175+
176+
# Text content
177+
if content and isinstance(content, str) and content.strip():
178+
parts.append(TextPart(content=content))
179+
180+
# Tool calls (from AIMessage.tool_calls)
181+
if tool_calls and isinstance(tool_calls, list):
182+
for tc in tool_calls:
183+
if not isinstance(tc, Mapping):
184+
continue
185+
name = tc.get("name")
186+
if not name:
187+
continue
188+
args = tc.get("args")
189+
args_str = None
190+
if args is not None:
191+
import json
192+
193+
try:
194+
args_str = json.dumps(args) if not isinstance(args, str) else args
195+
except (TypeError, ValueError):
196+
args_str = str(args)
197+
198+
parts.append(
199+
ToolCallRequestPart(
200+
name=name,
201+
id=tc.get("id"),
202+
arguments=args_str,
203+
)
204+
)
205+
206+
return parts
207+
208+
209+
def _map_base_message(msg: BaseMessage | Mapping[str, Any]) -> ChatMessage | None:
210+
"""Map a single LangChain message to an A365 ChatMessage."""
211+
role = _map_role(msg)
212+
parts = _extract_parts(msg)
213+
if not parts:
214+
return None
215+
216+
name = None
217+
if isinstance(msg, BaseMessage):
218+
name = getattr(msg, "name", None)
219+
220+
return ChatMessage(role=role, parts=parts, name=name)
221+
222+
223+
def _map_to_output_message(
224+
message_data: BaseMessage | Mapping[str, Any],
225+
generation: Mapping[str, Any],
226+
) -> OutputMessage | None:
227+
"""Map a LangChain generation to an A365 OutputMessage."""
228+
role = _map_role(message_data, default=MessageRole.ASSISTANT)
229+
parts = _extract_parts(message_data)
230+
if not parts:
231+
return None
232+
233+
# Extract finish_reason from generation metadata
234+
finish_reason = None
235+
gen_info = generation.get("generation_info")
236+
if isinstance(gen_info, Mapping):
237+
finish_reason = gen_info.get("finish_reason")
238+
239+
return OutputMessage(role=role, parts=parts, finish_reason=finish_reason)

libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py

Lines changed: 15 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Licensed under the MIT License.
33

44
import json
5-
from collections.abc import Iterable, Iterator, Mapping, Sequence
5+
from collections.abc import Iterable, Iterator, Mapping
66
from copy import deepcopy
77
from typing import Any
88

@@ -36,6 +36,9 @@
3636
stop_on_exception,
3737
)
3838

39+
from .message_mapper import map_input_messages as _map_input
40+
from .message_mapper import map_output_messages as _map_output
41+
3942
IGNORED_EXCEPTION_PATTERNS = [
4043
r"^Command\(",
4144
r"^ParentCommand\(",
@@ -207,42 +210,13 @@ def _parse_message_data(message_data: Mapping[str, Any] | None) -> Iterator[tupl
207210
def input_messages(
208211
inputs: Mapping[str, Any] | None,
209212
) -> Iterator[tuple[str, str]]:
210-
"""Yields chat messages as a JSON array of content strings."""
213+
"""Yields input messages in A365 versioned format."""
211214
if not inputs:
212215
return
213-
if not isinstance(inputs, Mapping):
214-
return
215-
# There may be more than one set of messages. We'll use just the first set.
216-
if not (multiple_messages := inputs.get("messages")):
217-
return
218-
if not isinstance(multiple_messages, Iterable):
219-
return
220-
# This will only get the first set of messages.
221-
if not (first_messages := next(iter(multiple_messages), None)):
222-
return
223-
contents: list[str] = []
224-
if isinstance(first_messages, list):
225-
for message_data in first_messages:
226-
if isinstance(message_data, BaseMessage):
227-
if hasattr(message_data, "content") and message_data.content:
228-
contents.append(str(message_data.content))
229-
elif hasattr(message_data, "get"):
230-
if content := message_data.get("content"):
231-
contents.append(str(content))
232-
elif kwargs := message_data.get("kwargs"):
233-
if hasattr(kwargs, "get") and (content := kwargs.get("content")):
234-
contents.append(str(content))
235-
elif isinstance(first_messages, BaseMessage):
236-
if hasattr(first_messages, "content") and first_messages.content:
237-
contents.append(str(first_messages.content))
238-
elif hasattr(first_messages, "get"):
239-
if content := first_messages.get("content"):
240-
contents.append(str(content))
241-
elif isinstance(first_messages, Sequence) and len(first_messages) == 2:
242-
role, content = first_messages
243-
contents.append(str(content))
244-
if contents:
245-
yield GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(contents)
216+
217+
mapped = _map_input(inputs)
218+
if mapped is not None:
219+
yield GEN_AI_INPUT_MESSAGES_KEY, mapped
246220

247221

248222
@stop_on_exception
@@ -266,44 +240,23 @@ def metadata(run: Run) -> Iterator[tuple[str, str]]:
266240
def output_messages(
267241
outputs: Mapping[str, Any] | None,
268242
) -> Iterator[tuple[str, str]]:
269-
"""Yields chat messages as a JSON array of content strings."""
243+
"""Yields output messages in A365 versioned format."""
270244
if not outputs:
271245
return
272246
if not isinstance(outputs, Mapping):
273247
return
248+
# Preserve response ID extraction
274249
output_type = outputs.get("type")
275250
if output_type and output_type.lower() == "llmresult":
276251
llm_output = outputs.get("llm_output")
277252
if llm_output and hasattr(llm_output, "get"):
278253
response_id = llm_output.get("id")
279254
if response_id:
280255
yield GEN_AI_RESPONSE_ID_KEY, response_id
281-
# There may be more than one set of generations. We'll use just the first set.
282-
if not (multiple_generations := outputs.get("generations")):
283-
return
284-
if not isinstance(multiple_generations, Iterable):
285-
return
286-
# This will only get the first set of generations.
287-
if not (first_generations := next(iter(multiple_generations), None)):
288-
return
289-
if not isinstance(first_generations, Iterable):
290-
return
291-
contents: list[str] = []
292-
for generation in first_generations:
293-
if not isinstance(generation, Mapping):
294-
continue
295-
if message_data := generation.get("message"):
296-
if isinstance(message_data, BaseMessage):
297-
if hasattr(message_data, "content") and message_data.content:
298-
contents.append(str(message_data.content))
299-
elif hasattr(message_data, "get"):
300-
if content := message_data.get("content"):
301-
contents.append(str(content))
302-
elif kwargs := message_data.get("kwargs"):
303-
if hasattr(kwargs, "get") and (content := kwargs.get("content")):
304-
contents.append(str(content))
305-
if contents:
306-
yield GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(contents)
256+
257+
mapped = _map_output(outputs)
258+
if mapped is not None:
259+
yield GEN_AI_OUTPUT_MESSAGES_KEY, mapped
307260

308261

309262
@stop_on_exception

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ dev-dependencies = [
5757
"agent-framework-azure-ai",
5858
"azure-identity",
5959
"openai-agents",
60+
"langchain-openai",
6061
]
6162

6263
# Override semantic-kernel's azure-ai-projects constraint to allow 2.x
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.

0 commit comments

Comments
 (0)