|
5 | 5 | from typing import Any, cast |
6 | 6 |
|
7 | 7 | from langchain_core.messages import ( |
| 8 | + AIMessage, |
8 | 9 | AIMessageChunk, |
9 | 10 | BaseMessage, |
| 11 | + ContentBlock, |
10 | 12 | HumanMessage, |
11 | 13 | TextContentBlock, |
12 | 14 | ToolCall, |
13 | 15 | ToolMessage, |
14 | 16 | ) |
| 17 | +from langchain_core.messages.content import create_text_block |
15 | 18 | from pydantic import ValidationError |
16 | 19 | from uipath.core.chat import ( |
17 | 20 | UiPathConversationContentPartChunkEvent, |
@@ -66,7 +69,7 @@ def map_messages(self, messages: list[Any]) -> list[Any]: |
66 | 69 | """Normalize any 'messages' list into LangChain messages. |
67 | 70 |
|
68 | 71 | - If already BaseMessage instances: return as-is. |
69 | | - - If UiPathConversationMessage: convert to HumanMessage. |
| 72 | + - If UiPathConversationMessage: convert to LangChain message |
70 | 73 | """ |
71 | 74 | if not isinstance(messages, list): |
72 | 75 | raise TypeError("messages must be a list") |
@@ -104,46 +107,108 @@ def map_messages(self, messages: list[Any]) -> list[Any]: |
104 | 107 |
|
105 | 108 | def _map_messages_internal( |
106 | 109 | self, messages: list[UiPathConversationMessage] |
107 | | - ) -> list[HumanMessage]: |
| 110 | + ) -> list[BaseMessage]: |
108 | 111 | """ |
109 | | - Converts a UiPathConversationMessage into a list of HumanMessages for LangGraph. |
110 | | - Supports multimodal content parts (text, external content) and preserves metadata. |
| 112 | + Converts UiPathConversationMessage list to LangChain messages (UserMessage/AIMessage/ToolMessage list). |
| 113 | + - All content parts are combined into content_blocks |
| 114 | + - Tool calls are converted to LangChain ToolCall format, with results stored as ToolMessage |
| 115 | + - Metadata includes message_id, role, timestamps |
111 | 116 | """ |
112 | | - human_messages: list[HumanMessage] = [] |
113 | | - |
114 | | - for uipath_msg in messages: |
115 | | - # Loop over each content part |
116 | | - if uipath_msg.content_parts: |
117 | | - for part in uipath_msg.content_parts: |
118 | | - data = part.data |
119 | | - content = "" |
120 | | - metadata: dict[str, Any] = { |
121 | | - "message_id": uipath_msg.message_id, |
122 | | - "content_part_id": part.content_part_id, |
123 | | - "mime_type": part.mime_type, |
124 | | - "created_at": uipath_msg.created_at, |
125 | | - "updated_at": uipath_msg.updated_at, |
126 | | - } |
127 | | - |
128 | | - if isinstance(data, UiPathInlineValue): |
129 | | - content = str(data.inline) |
130 | | - |
131 | | - # Append a HumanMessage for this content part |
132 | | - human_messages.append( |
133 | | - HumanMessage(content=content, metadata=metadata) |
| 117 | + converted_messages: list[BaseMessage] = [] |
| 118 | + |
| 119 | + for uipath_message in messages: |
| 120 | + content_blocks: list[ContentBlock] = [] |
| 121 | + |
| 122 | + # Convert content_parts to content_blocks |
| 123 | + # TODO: Convert file-attachment content-parts to content_blocks as well |
| 124 | + if uipath_message.content_parts: |
| 125 | + for uipath_content_part in uipath_message.content_parts: |
| 126 | + data = uipath_content_part.data |
| 127 | + if uipath_content_part.mime_type.startswith("text/") and isinstance( |
| 128 | + data, UiPathInlineValue |
| 129 | + ): |
| 130 | + text = str(data.inline) |
| 131 | + if text: |
| 132 | + content_blocks.append( |
| 133 | + create_text_block( |
| 134 | + text, id=uipath_content_part.content_part_id |
| 135 | + ) |
| 136 | + ) |
| 137 | + |
| 138 | + # Metadata for the user/assistant message |
| 139 | + metadata = { |
| 140 | + "message_id": uipath_message.message_id, |
| 141 | + "created_at": uipath_message.created_at, |
| 142 | + "updated_at": uipath_message.updated_at, |
| 143 | + } |
| 144 | + |
| 145 | + role = uipath_message.role |
| 146 | + if role == "user": |
| 147 | + converted_messages.append( |
| 148 | + HumanMessage( |
| 149 | + id=uipath_message.message_id, |
| 150 | + content_blocks=content_blocks, |
| 151 | + additional_kwargs=metadata, |
134 | 152 | ) |
| 153 | + ) |
135 | 154 |
|
136 | | - # Handle the case where there are no content parts |
137 | | - else: |
138 | | - metadata = { |
139 | | - "message_id": uipath_msg.message_id, |
140 | | - "role": uipath_msg.role, |
141 | | - "created_at": uipath_msg.created_at, |
142 | | - "updated_at": uipath_msg.updated_at, |
143 | | - } |
144 | | - human_messages.append(HumanMessage(content="", metadata=metadata)) |
145 | | - |
146 | | - return human_messages |
| 155 | + elif role == "assistant": |
| 156 | + # Convert tool calls to LangChain format |
| 157 | + tool_calls: list[ToolCall] = [] |
| 158 | + tool_messages: list[ToolMessage] = [] |
| 159 | + if uipath_message.tool_calls: |
| 160 | + for uipath_tool_call in uipath_message.tool_calls: |
| 161 | + tool_call = ToolCall( |
| 162 | + name=uipath_tool_call.name.replace(" ", "_"), |
| 163 | + args=uipath_tool_call.input or {}, |
| 164 | + id=uipath_tool_call.tool_call_id, |
| 165 | + ) |
| 166 | + tool_calls.append(tool_call) |
| 167 | + |
| 168 | + tool_call_output = ( |
| 169 | + uipath_tool_call.result.output |
| 170 | + if uipath_tool_call.result |
| 171 | + else None |
| 172 | + ) |
| 173 | + tool_call_status = ( |
| 174 | + "success" |
| 175 | + if uipath_tool_call.result |
| 176 | + and not uipath_tool_call.result.is_error |
| 177 | + else "error" |
| 178 | + ) |
| 179 | + |
| 180 | + # Serialize output to string if needed |
| 181 | + if tool_call_output is None: |
| 182 | + content = "" |
| 183 | + elif isinstance(tool_call_output, str): |
| 184 | + content = tool_call_output |
| 185 | + else: |
| 186 | + content = json.dumps(tool_call_output) |
| 187 | + |
| 188 | + tool_messages.append( |
| 189 | + ToolMessage( |
| 190 | + content=content, |
| 191 | + status=tool_call_status, |
| 192 | + tool_call_id=uipath_tool_call.tool_call_id, |
| 193 | + ) |
| 194 | + ) |
| 195 | + |
| 196 | + # Ideally we pass in content_blocks here rather than string content, but when doing so, OpenAI errors unless a msg_ prefix is used for content-block IDs. |
| 197 | + # When needed, we can switch to content_blocks but need to work out a common ID strategy across models for the content-block IDs. |
| 198 | + converted_messages.append( |
| 199 | + AIMessage( |
| 200 | + id=uipath_message.message_id, |
| 201 | + # content_blocks=content_blocks, |
| 202 | + content=self._extract_text(content_blocks) |
| 203 | + if content_blocks |
| 204 | + else "", |
| 205 | + tool_calls=tool_calls, |
| 206 | + additional_kwargs=metadata, |
| 207 | + ) |
| 208 | + ) |
| 209 | + converted_messages.extend(tool_messages) |
| 210 | + |
| 211 | + return converted_messages |
147 | 212 |
|
148 | 213 | async def map_event( |
149 | 214 | self, |
|
0 commit comments