Skip to content

Commit bc14bd3

Browse files
authored
Merge pull request #297 from UiPath/feature/add-message-mapping-methods
feat: add mapper for chat messages
2 parents fb053c9 + 0e7dc5a commit bc14bd3

File tree

5 files changed

+2130
-1796
lines changed

5 files changed

+2130
-1796
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.1.9"
3+
version = "0.1.10"
44
description = "UiPath Langchain"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8+
"uipath-core>=0.0.6, <0.1.0",
89
"uipath-runtime>=0.1.2, <0.2.0",
910
"uipath>=2.2.11, <2.3.0",
1011
"langgraph>=1.0.0, <2.0.0",
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1+
from .mapper import UiPathChatMessagesMapper
12
from .models import UiPathAzureChatOpenAI, UiPathChat
23

3-
__all__ = [
4-
"UiPathChat",
5-
"UiPathAzureChatOpenAI",
6-
]
4+
__all__ = ["UiPathChat", "UiPathAzureChatOpenAI", "UiPathChatMessagesMapper"]
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import json
2+
import logging
3+
import uuid
4+
from datetime import datetime, timezone
5+
from typing import Any, cast
6+
7+
from langchain_core.messages import (
8+
AIMessageChunk,
9+
BaseMessage,
10+
HumanMessage,
11+
TextContentBlock,
12+
ToolCallChunk,
13+
ToolMessage,
14+
)
15+
from uipath.core.chat import (
16+
UiPathConversationContentPartChunkEvent,
17+
UiPathConversationContentPartEndEvent,
18+
UiPathConversationContentPartEvent,
19+
UiPathConversationContentPartStartEvent,
20+
UiPathConversationEvent,
21+
UiPathConversationExchangeEvent,
22+
UiPathConversationMessage,
23+
UiPathConversationMessageEndEvent,
24+
UiPathConversationMessageEvent,
25+
UiPathConversationMessageStartEvent,
26+
UiPathConversationToolCallEndEvent,
27+
UiPathConversationToolCallEvent,
28+
UiPathConversationToolCallStartEvent,
29+
UiPathInlineValue,
30+
)
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
class UiPathChatMessagesMapper:
36+
"""Stateful mapper that converts LangChain messages to UiPath conversation events.
37+
38+
Maintains state across multiple message conversions to properly track:
39+
- The AI message ID associated with each tool call for proper correlation with ToolMessage
40+
"""
41+
42+
def __init__(self):
43+
"""Initialize the mapper with empty state."""
44+
self.tool_call_to_ai_message: dict[str, str] = {}
45+
self.seen_message_ids: set[str] = set()
46+
47+
def _wrap_in_conversation_event(
48+
self,
49+
msg_event: UiPathConversationMessageEvent,
50+
exchange_id: str | None = None,
51+
conversation_id: str | None = None,
52+
) -> UiPathConversationEvent:
53+
"""Helper to wrap a message event into a conversation-level event."""
54+
return UiPathConversationEvent(
55+
conversation_id=conversation_id or str(uuid.uuid4()),
56+
exchange=UiPathConversationExchangeEvent(
57+
exchange_id=exchange_id or str(uuid.uuid4()),
58+
message=msg_event,
59+
),
60+
)
61+
62+
def _extract_text(self, content: Any) -> str:
63+
"""Normalize LangGraph message.content to plain text."""
64+
if isinstance(content, str):
65+
return content
66+
if isinstance(content, list):
67+
return "".join(
68+
part.get("text", "")
69+
for part in content
70+
if isinstance(part, dict) and part.get("type") == "text"
71+
)
72+
return str(content or "")
73+
74+
def map_messages(self, messages: list[Any]) -> list[Any]:
75+
"""Normalize any 'messages' list into LangChain messages.
76+
77+
- If already BaseMessage instances: return as-is.
78+
- If UiPathConversationMessage: convert to HumanMessage.
79+
"""
80+
if not isinstance(messages, list):
81+
raise TypeError("messages must be a list")
82+
83+
if not messages:
84+
return []
85+
86+
first = messages[0]
87+
88+
# Case 1: already LangChain messages
89+
if isinstance(first, BaseMessage):
90+
return cast(list[BaseMessage], messages)
91+
92+
# Case 2: UiPath messages -> convert to HumanMessage
93+
if isinstance(first, UiPathConversationMessage):
94+
if not all(isinstance(m, UiPathConversationMessage) for m in messages):
95+
raise TypeError("Mixed message types not supported")
96+
return self._map_messages_internal(
97+
cast(list[UiPathConversationMessage], messages)
98+
)
99+
100+
# Fallback: unknown type – just pass through
101+
return messages
102+
103+
def _map_messages_internal(
104+
self, messages: list[UiPathConversationMessage]
105+
) -> list[HumanMessage]:
106+
"""
107+
Converts a UiPathConversationMessage into a list of HumanMessages for LangGraph.
108+
Supports multimodal content parts (text, external content) and preserves metadata.
109+
"""
110+
human_messages: list[HumanMessage] = []
111+
112+
for uipath_msg in messages:
113+
# Loop over each content part
114+
if uipath_msg.content_parts:
115+
for part in uipath_msg.content_parts:
116+
data = part.data
117+
content = ""
118+
metadata: dict[str, Any] = {
119+
"message_id": uipath_msg.message_id,
120+
"content_part_id": part.content_part_id,
121+
"mime_type": part.mime_type,
122+
"created_at": uipath_msg.created_at,
123+
"updated_at": uipath_msg.updated_at,
124+
}
125+
126+
if isinstance(data, UiPathInlineValue):
127+
content = str(data.inline)
128+
129+
# Append a HumanMessage for this content part
130+
human_messages.append(
131+
HumanMessage(content=content, metadata=metadata)
132+
)
133+
134+
# Handle the case where there are no content parts
135+
else:
136+
metadata = {
137+
"message_id": uipath_msg.message_id,
138+
"role": uipath_msg.role,
139+
"created_at": uipath_msg.created_at,
140+
"updated_at": uipath_msg.updated_at,
141+
}
142+
human_messages.append(HumanMessage(content="", metadata=metadata))
143+
144+
return human_messages
145+
146+
def map_event(
147+
self,
148+
message: BaseMessage,
149+
exchange_id: str | None = None,
150+
conversation_id: str | None = None,
151+
) -> UiPathConversationEvent | None:
152+
"""Convert LangGraph BaseMessage (chunk or full) into a UiPathConversationEvent.
153+
154+
Args:
155+
message: The LangChain message to convert
156+
exchange_id: Optional exchange ID for the conversation
157+
conversation_id: Optional conversation ID
158+
159+
Returns:
160+
A UiPathConversationEvent if the message should be emitted, None otherwise
161+
"""
162+
# Format timestamp as ISO 8601 UTC with milliseconds: 2025-01-04T10:30:00.123Z
163+
timestamp = (
164+
datetime.now(timezone.utc)
165+
.isoformat(timespec="milliseconds")
166+
.replace("+00:00", "Z")
167+
)
168+
169+
# --- Streaming AIMessageChunk ---
170+
if isinstance(message, AIMessageChunk):
171+
if message.id is None:
172+
return None
173+
174+
msg_event = UiPathConversationMessageEvent(
175+
message_id=message.id,
176+
)
177+
178+
# Check if this is the last chunk by examining chunk_position
179+
if message.chunk_position == "last":
180+
msg_event.end = UiPathConversationMessageEndEvent(timestamp=timestamp)
181+
msg_event.content_part = UiPathConversationContentPartEvent(
182+
content_part_id=f"chunk-{message.id}-0",
183+
end=UiPathConversationContentPartEndEvent(),
184+
)
185+
return self._wrap_in_conversation_event(
186+
msg_event, exchange_id, conversation_id
187+
)
188+
189+
# For every new message_id, start a new message
190+
if message.id not in self.seen_message_ids:
191+
self.seen_message_ids.add(message.id)
192+
msg_event.start = UiPathConversationMessageStartEvent(
193+
role="assistant", timestamp=timestamp
194+
)
195+
msg_event.content_part = UiPathConversationContentPartEvent(
196+
content_part_id=f"chunk-{message.id}-0",
197+
start=UiPathConversationContentPartStartEvent(
198+
mime_type="text/plain"
199+
),
200+
)
201+
202+
elif message.content_blocks:
203+
for block in message.content_blocks:
204+
block_type = block.get("type")
205+
206+
if block_type == "text":
207+
text_block = cast(TextContentBlock, block)
208+
text = text_block["text"]
209+
210+
msg_event.content_part = UiPathConversationContentPartEvent(
211+
content_part_id=f"chunk-{message.id}-0",
212+
chunk=UiPathConversationContentPartChunkEvent(
213+
data=text,
214+
content_part_sequence=0,
215+
),
216+
)
217+
218+
elif block_type == "tool_call_chunk":
219+
tool_chunk_block = cast(ToolCallChunk, block)
220+
221+
tool_call_id = tool_chunk_block.get("id")
222+
if tool_call_id:
223+
# Track tool_call_id -> ai_message_id mapping
224+
self.tool_call_to_ai_message[str(tool_call_id)] = message.id
225+
226+
args = tool_chunk_block.get("args") or ""
227+
228+
msg_event.content_part = UiPathConversationContentPartEvent(
229+
content_part_id=f"chunk-{message.id}-0",
230+
chunk=UiPathConversationContentPartChunkEvent(
231+
data=args,
232+
content_part_sequence=0,
233+
),
234+
)
235+
# Continue so that multiple tool_call_chunks in the same block list
236+
# are handled correctly
237+
continue
238+
239+
# Fallback: raw string content on the chunk (rare when using content_blocks)
240+
elif isinstance(message.content, str) and message.content:
241+
msg_event.content_part = UiPathConversationContentPartEvent(
242+
content_part_id=f"content-{message.id}",
243+
chunk=UiPathConversationContentPartChunkEvent(
244+
data=message.content,
245+
content_part_sequence=0,
246+
),
247+
)
248+
249+
if (
250+
msg_event.start
251+
or msg_event.content_part
252+
or msg_event.tool_call
253+
or msg_event.end
254+
):
255+
return self._wrap_in_conversation_event(
256+
msg_event, exchange_id, conversation_id
257+
)
258+
259+
return None
260+
261+
# --- ToolMessage ---
262+
if isinstance(message, ToolMessage):
263+
# Look up the AI message ID using the tool_call_id
264+
result_message_id = (
265+
self.tool_call_to_ai_message.get(message.tool_call_id)
266+
if message.tool_call_id
267+
else None
268+
)
269+
270+
# If no AI message ID was found, we cannot properly associate this tool result
271+
if not result_message_id:
272+
logger.warning(
273+
f"Tool message {message.tool_call_id} has no associated AI message ID. Skipping."
274+
)
275+
return None
276+
277+
# Clean up the mapping after use
278+
if message.tool_call_id:
279+
del self.tool_call_to_ai_message[message.tool_call_id]
280+
281+
content_value: Any = message.content
282+
if isinstance(content_value, str):
283+
try:
284+
content_value = json.loads(content_value)
285+
except (json.JSONDecodeError, TypeError):
286+
# Keep as string if not valid JSON
287+
pass
288+
289+
return self._wrap_in_conversation_event(
290+
UiPathConversationMessageEvent(
291+
message_id=result_message_id,
292+
tool_call=UiPathConversationToolCallEvent(
293+
tool_call_id=message.tool_call_id,
294+
start=UiPathConversationToolCallStartEvent(
295+
tool_name=message.name,
296+
arguments=None,
297+
timestamp=timestamp,
298+
),
299+
end=UiPathConversationToolCallEndEvent(
300+
timestamp=timestamp,
301+
result=UiPathInlineValue(inline=content_value),
302+
),
303+
),
304+
),
305+
exchange_id,
306+
conversation_id,
307+
)
308+
309+
# --- Fallback for other BaseMessage types ---
310+
text_content = self._extract_text(message.content)
311+
return self._wrap_in_conversation_event(
312+
UiPathConversationMessageEvent(
313+
message_id=message.id,
314+
start=UiPathConversationMessageStartEvent(
315+
role="assistant", timestamp=timestamp
316+
),
317+
content_part=UiPathConversationContentPartEvent(
318+
content_part_id=f"cp-{message.id}",
319+
chunk=UiPathConversationContentPartChunkEvent(data=text_content),
320+
),
321+
end=UiPathConversationMessageEndEvent(),
322+
),
323+
exchange_id,
324+
conversation_id,
325+
)
326+
327+
328+
__all__ = ["UiPathChatMessagesMapper"]

0 commit comments

Comments
 (0)