From b49a1a3166f52f323f15341ce536ab62a7feca1e Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:42:37 -0600 Subject: [PATCH 1/8] feat: output conversational message data in terminate-node --- src/uipath_langchain/agent/react/init_node.py | 8 ++ .../agent/react/terminate_node.py | 53 ++++++- src/uipath_langchain/agent/react/types.py | 1 + src/uipath_langchain/runtime/messages.py | 136 +++++++++++++++++- tests/runtime/chat_message_mapper.py | 11 ++ 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index bbf1a18a7..d004de70c 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -44,11 +44,19 @@ def graph_state_init(state: Any) -> Any: str(att.id): att for att in job_attachments if att.id is not None } + # Calculate initial message count for tracking new messages + initial_message_count = ( + len(resolved_messages.value) + if isinstance(resolved_messages, Overwrite) + else len(resolved_messages) + ) + return { "messages": resolved_messages, "inner_state": { "job_attachments": job_attachments_dict, "agent_settings": agent_settings, + "initial_message_count": initial_message_count, }, } diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index c7591a06c..93c410505 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -7,8 +7,10 @@ from langchain_core.messages import AIMessage from pydantic import BaseModel from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL +from uipath.core.chat import UiPathConversationMessageData from uipath.runtime.errors import UiPathErrorCategory +from ...runtime.messages import UiPathChatMessagesMapper from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode from .types import AgentGraphState @@ -34,18 +36,65 @@ def _handle_raise_error(args: dict[str, Any]) -> NoReturn: ) +def _handle_end_conversational( + state: AgentGraphState, response_schema: type[BaseModel] | None +) -> dict[str, Any]: + """Handle conversational agent termination by returning converted messages.""" + if state.inner_state.initial_message_count is None: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="No initial message count in state for conversational agent execution.", + detail="Initial message count must be set in inner_state for conversational agent execution.", + category=UiPathErrorCategory.SYSTEM, + ) + + if response_schema is None: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="No response schema for conversational agent termination.", + detail="Response schema must be provided for termination of conversational agent execution.", + category=UiPathErrorCategory.SYSTEM, + ) + + initial_count = state.inner_state.initial_message_count + new_messages = state.messages[initial_count:] + + converted_messages: list[UiPathConversationMessageData] = [] + + # For the agent-output messages, don't include tool-results. Just include agent's response choices (LLM outputs and tool-calls). + # This is for simpler agent output and because evaluations don't check for tool-results. + if new_messages: + converted_messages = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages=new_messages, include_tool_results=False + ) + ) + + output = { + "uipath__agent_response_messages": [ + msg.model_dump(by_alias=True) for msg in converted_messages + ] + } + validated = response_schema.model_validate(output) + return validated.model_dump() + + def create_terminate_node( - response_schema: type[BaseModel] | None = None, is_conversational: bool = False + response_schema: type[BaseModel] | None = None, + is_conversational: bool = False, ): """Handles Agent Graph termination for multiple sources and output or error propagation to Orchestrator. Termination scenarios: 1. LLM-initiated termination (END_EXECUTION_TOOL) 2. LLM-initiated error (RAISE_ERROR_TOOL) + 3. End of Conversational Agent loop """ def terminate_node(state: AgentGraphState): - if not is_conversational: + if is_conversational: + return _handle_end_conversational(state, response_schema) + else: last_message = state.messages[-1] if not isinstance(last_message, AIMessage): raise AgentRuntimeError( diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 5ec0e27c1..502ff62c6 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -26,6 +26,7 @@ class AgentSettings(BaseModel): class InnerAgentGraphState(BaseModel): job_attachments: Annotated[dict[str, Attachment], merge_dicts] = {} agent_settings: AgentSettings | None = None + initial_message_count: int | None = None tools_storage: Annotated[dict[Hashable, Any], merge_dicts] = {} diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 038d37aba..2b5325180 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -7,6 +7,7 @@ from langchain_core.messages import ( AIMessage, AIMessageChunk, + AnyMessage, BaseMessage, ContentBlock, HumanMessage, @@ -18,15 +19,19 @@ from pydantic import ValidationError from uipath.core.chat import ( UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartData, UiPathConversationContentPartEndEvent, UiPathConversationContentPartEvent, UiPathConversationContentPartStartEvent, UiPathConversationMessage, + UiPathConversationMessageData, UiPathConversationMessageEndEvent, UiPathConversationMessageEvent, UiPathConversationMessageStartEvent, + UiPathConversationToolCallData, UiPathConversationToolCallEndEvent, UiPathConversationToolCallEvent, + UiPathConversationToolCallResult, UiPathConversationToolCallStartEvent, UiPathInlineValue, ) @@ -53,7 +58,8 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() - def _extract_text(self, content: Any) -> str: + @staticmethod + def _extract_text(content: Any) -> str: """Normalize LangGraph message.content to plain text.""" if isinstance(content, str): return content @@ -199,7 +205,7 @@ def _map_messages_internal( AIMessage( id=uipath_message.message_id, # content_blocks=content_blocks, - content=self._extract_text(content_blocks) + content=UiPathChatMessagesMapper._extract_text(content_blocks) if content_blocks else "", tool_calls=tool_calls, @@ -490,5 +496,131 @@ def map_to_message_end_event( ), ) + # Static methods for mapping langchain messages to uipath message types + + @staticmethod + def map_langchain_messages_to_uipath_message_data_list( + messages: list[AnyMessage], include_tool_results: bool = True + ) -> list[UiPathConversationMessageData]: + """Convert LangChain messages to UiPathConversationMessageData format. include_tool_results controls whether to include tool call results from ToolMessage instances in the output agent-messages.""" + + # Build map of tool_call_id -> ToolMessage lookup, if tool-results should be included + tool_messages_map = ( + UiPathChatMessagesMapper._build_langchain_tool_messages_map(messages) + if include_tool_results + else None + ) + + converted_messages: list[UiPathConversationMessageData] = [] + + for message in messages: + if isinstance(message, HumanMessage): + converted_messages.append( + UiPathChatMessagesMapper._map_langchain_human_message_to_uipath_message_data( + message + ) + ) + elif isinstance(message, AIMessage): + converted_messages.append( + UiPathChatMessagesMapper._map_langchain_ai_message_to_uipath_message_data( + message, tool_messages_map + ) + ) + + return converted_messages + + @staticmethod + def _build_langchain_tool_messages_map( + messages: list[AnyMessage], + ) -> dict[str, ToolMessage]: + """Create mapping of tool_call_id -> ToolMessage for efficient lookup.""" + tool_map: dict[str, ToolMessage] = {} + for msg in messages: + if isinstance(msg, ToolMessage) and msg.tool_call_id: + tool_map[msg.tool_call_id] = msg + return tool_map + + @staticmethod + def _parse_langchain_tool_result(content: Any) -> Any: + """Attempt to parse JSON result back to dict (reverse of json.dumps).""" + if not content or not isinstance(content, str): + return content + + try: + return json.loads(content) + except (json.JSONDecodeError, TypeError): + # Not valid JSON, return as string + return content + + @staticmethod + def _map_langchain_human_message_to_uipath_message_data( + message: HumanMessage, + ) -> UiPathConversationMessageData: + """Convert HumanMessage to UiPathConversationMessageData.""" + + text_content = UiPathChatMessagesMapper._extract_text(message.content) + content_parts: list[UiPathConversationContentPartData] = [] + if text_content: + content_parts.append( + UiPathConversationContentPartData( + mime_type="text/plain", + data=UiPathInlineValue(inline=text_content), + citations=[], + ) + ) + + return UiPathConversationMessageData( + role="user", content_parts=content_parts, tool_calls=[], interrupts=[] + ) + + @staticmethod + def _map_langchain_ai_message_to_uipath_message_data( + message: AIMessage, tool_message_map: dict[str, ToolMessage] | None + ) -> UiPathConversationMessageData: + """Convert AIMessage to UiPathConversationMessageData with embedded tool-calls. When tool_message_map is passed in, tool results are matched by tool-call ID and included.""" + + content_parts: list[UiPathConversationContentPartData] = [] + text_content = UiPathChatMessagesMapper._extract_text(message.content) + if text_content: + content_parts.append( + UiPathConversationContentPartData( + mime_type="text/markdown", + data=UiPathInlineValue(inline=text_content), + citations=[], # TODO: Citations + ) + ) + + # Convert tool_calls + uipath_tool_calls: list[UiPathConversationToolCallData] = [] + if message.tool_calls: + for tool_call in message.tool_calls: + uipath_tool_call = UiPathConversationToolCallData( + name=tool_call["name"], input=tool_call.get("args", {}) + ) + + if tool_message_map and tool_call["id"]: + # Find corresponding ToolMessage and build tool-call result if found + tool_message = tool_message_map.get(tool_call["id"]) + result = None + if tool_message: + # Parse JSON result back to dict + output = UiPathChatMessagesMapper._parse_langchain_tool_result( + tool_message.content + ) + result = UiPathConversationToolCallResult( + output=output, + is_error=tool_message.status == "error", + ) + uipath_tool_call.result = result + + uipath_tool_calls.append(uipath_tool_call) + + return UiPathConversationMessageData( + role="assistant", + content_parts=content_parts, + tool_calls=uipath_tool_calls, + interrupts=[], # TODO: Interrupts + ) + __all__ = ["UiPathChatMessagesMapper"] diff --git a/tests/runtime/chat_message_mapper.py b/tests/runtime/chat_message_mapper.py index db69aadb7..032d5fc76 100644 --- a/tests/runtime/chat_message_mapper.py +++ b/tests/runtime/chat_message_mapper.py @@ -134,6 +134,7 @@ def test_map_messages_converts_uipath_messages(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="hello world"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -216,6 +217,7 @@ def test_map_messages_handles_user_message_with_multiple_content_parts(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="first part"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ), @@ -223,6 +225,7 @@ def test_map_messages_handles_user_message_with_multiple_content_parts(self): content_part_id="part-2", mime_type="text/plain", data=UiPathInlineValue(inline="second part"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ), @@ -277,6 +280,7 @@ def test_map_messages_handles_assistant_message_without_tool_calls(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="I can help with that!"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -309,6 +313,7 @@ def test_map_messages_handles_tool_calls_without_results(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="Let me search for that."), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -360,6 +365,7 @@ def test_map_messages_includes_tool_calls_with_results(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="Let me search for that."), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -643,6 +649,7 @@ def test_map_messages_handles_mixed_user_and_assistant_messages(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="Hello"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -660,6 +667,7 @@ def test_map_messages_handles_mixed_user_and_assistant_messages(self): content_part_id="part-2", mime_type="text/plain", data=UiPathInlineValue(inline="Hi there!"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -677,6 +685,7 @@ def test_map_messages_handles_mixed_user_and_assistant_messages(self): content_part_id="part-3", mime_type="text/plain", data=UiPathInlineValue(inline="How are you?"), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ) @@ -706,6 +715,7 @@ def test_map_messages_handles_assistant_with_multiple_content_parts(self): content_part_id="part-1", mime_type="text/plain", data=UiPathInlineValue(inline="First part. "), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ), @@ -713,6 +723,7 @@ def test_map_messages_handles_assistant_with_multiple_content_parts(self): content_part_id="part-2", mime_type="text/plain", data=UiPathInlineValue(inline="Second part."), + citations=[], created_at=TEST_TIMESTAMP, updated_at=TEST_TIMESTAMP, ), From 08f8919727d110dd91f962dd90782c1593092a7c Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:47:17 -0600 Subject: [PATCH 2/8] fix: comment --- src/uipath_langchain/agent/react/terminate_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index 93c410505..0214a13e4 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -88,7 +88,7 @@ def create_terminate_node( Termination scenarios: 1. LLM-initiated termination (END_EXECUTION_TOOL) 2. LLM-initiated error (RAISE_ERROR_TOOL) - 3. End of Conversational Agent loop + 3. End of conversational loop """ def terminate_node(state: AgentGraphState): From c23e9f26653fe20184253186b78c4f5c0675e61d Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:16:14 -0600 Subject: [PATCH 3/8] fix: tests and aliasing --- .../agent/react/terminate_node.py | 2 +- tests/agent/react/test_init_node.py | 38 ++- tests/agent/react/test_terminate_node.py | 181 +++++++++--- tests/runtime/chat_message_mapper.py | 259 ++++++++++++++++++ 4 files changed, 447 insertions(+), 33 deletions(-) diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index 0214a13e4..dcb143a24 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -76,7 +76,7 @@ def _handle_end_conversational( ] } validated = response_schema.model_validate(output) - return validated.model_dump() + return validated.model_dump(by_alias=True) def create_terminate_node( diff --git a/tests/agent/react/test_init_node.py b/tests/agent/react/test_init_node.py index 1bf22ae40..feeeddafc 100644 --- a/tests/agent/react/test_init_node.py +++ b/tests/agent/react/test_init_node.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from langgraph.types import Overwrite from pydantic import BaseModel @@ -246,3 +246,39 @@ def test_conversational_merges_attachments_from_preserved_messages(self): assert attachment_id in job_attachments assert job_attachments[attachment_id].full_name == "document.pdf" assert job_attachments[attachment_id].mime_type == "application/pdf" + + def test_initial_message_count_in_non_conversational_mode(self): + """Non-conversational mode should set initial_message_count.""" + messages: list[SystemMessage | HumanMessage] = [ + SystemMessage(content="System"), + HumanMessage(content="Query"), + ] + init_node = create_init_node( + messages, input_schema=None, is_conversational=False + ) + state = MockState(messages=[]) + + result = init_node(state) + + assert "initial_message_count" in result["inner_state"] + # In non-conversational mode, messages is a list + assert result["inner_state"]["initial_message_count"] == 2 + + def test_initial_message_count_in_conversational_mode(self): + """Conversational mode should set initial_message_count based on Overwrite.""" + messages: list[SystemMessage | HumanMessage] = [ + SystemMessage(content="System"), + HumanMessage(content="Query"), + AIMessage(content="Response"), + HumanMessage(content="Query"), + AIMessage(content="Response"), + ] + init_node = create_init_node( + messages, input_schema=None, is_conversational=True + ) + state = MockState(messages=[]) + + result = init_node(state) + + assert "initial_message_count" in result["inner_state"] + assert result["inner_state"]["initial_message_count"] == 5 diff --git a/tests/agent/react/test_terminate_node.py b/tests/agent/react/test_terminate_node.py index 7fa1213ca..808d36c42 100644 --- a/tests/agent/react/test_terminate_node.py +++ b/tests/agent/react/test_terminate_node.py @@ -3,7 +3,8 @@ from typing import Any import pytest -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from uipath.core.chat import UiPathConversationMessageData from pydantic import BaseModel from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL @@ -18,6 +19,7 @@ class MockInnerState(BaseModel): """Mock inner state for testing.""" job_attachments: dict[str, Any] = {} + initial_message_count: int | None = None class MockAgentGraphState(BaseModel): @@ -30,44 +32,154 @@ class MockAgentGraphState(BaseModel): class TestTerminateNodeConversational: """Test cases for create_terminate_node with is_conversational=True.""" - @pytest.fixture - def terminate_node(self): - """Fixture for conversational terminate node.""" - return create_terminate_node(response_schema=None, is_conversational=True) + def test_conversational_requires_response_schema(self): + """Conversational mode should raise error if no response_schema provided.""" - @pytest.fixture - def state_with_ai_message(self): - """Fixture for state with AI message (no tool calls).""" - return MockAgentGraphState( - messages=[AIMessage(content="Here is my response to your question.")] + terminate_node_no_schema = create_terminate_node( + response_schema=None, is_conversational=True + ) + state = MockAgentGraphState( + messages=[ + HumanMessage(content="Initial message"), + AIMessage(content="Response"), + ], + inner_state=MockInnerState(initial_message_count=1), ) - @pytest.fixture - def state_with_human_message(self): - """Fixture for state with human message as last.""" - return MockAgentGraphState(messages=[HumanMessage(content="User message")]) + with pytest.raises(AgentRuntimeError) as exc_info: + terminate_node_no_schema(state) - def test_conversational_returns_none_no_tool_calls( - self, terminate_node, state_with_ai_message - ): - """Conversational mode should return None when AI has no tool calls.""" - result = terminate_node(state_with_ai_message) + assert "No response schema" in exc_info.value.error_info.title - assert result is None + def test_conversational_requires_initial_message_count(self): + """Conversational mode should raise error if initial_message_count not set.""" - def test_conversational_skips_ai_message_validation( - self, terminate_node, state_with_human_message - ): - """Conversational mode should not validate that last message is AIMessage.""" - # This should not raise, unlike non-conversational mode - result = terminate_node(state_with_human_message) + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + + terminate_node = create_terminate_node( + response_schema=ResponseSchema, is_conversational=True + ) + state = MockAgentGraphState( + messages=[AIMessage(content="Response")], + inner_state=MockInnerState(initial_message_count=None), + ) + + with pytest.raises(AgentRuntimeError) as exc_info: + terminate_node(state) + + assert "No initial message count" in exc_info.value.error_info.title + + def test_conversational_returns_converted_messages(self): + """Conversational mode should return converted new messages.""" + + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + + terminate_node = create_terminate_node( + response_schema=ResponseSchema, is_conversational=True + ) + + # Create state with initial message count of 2, and 3 total messages + # So only the last message should be converted + state = MockAgentGraphState( + messages=[ + HumanMessage(content="Initial user message"), + AIMessage(content="Initial AI response"), + AIMessage(content="New AI response"), + ], + inner_state=MockInnerState(initial_message_count=2), + ) + + result = terminate_node(state) - assert result is None + assert "uipath__agent_response_messages" in result + messages = result["uipath__agent_response_messages"] + + # Should have 1 message (only the new one after initial_message_count) + assert len(messages) == 1 + assert messages[0]["role"] == "assistant" + assert len(messages[0]["contentParts"]) == 1 + assert messages[0]["contentParts"][0]["mimeType"] == "text/markdown" + assert "New AI response" in str(messages[0]["contentParts"][0]["data"]) + + def test_conversational_handles_multiple_new_messages(self): + """Conversational mode should convert all messages after initial count.""" + + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + + terminate_node = create_terminate_node( + response_schema=ResponseSchema, is_conversational=True + ) + + # Initial count is 1, so messages at index 1+ are new + state = MockAgentGraphState( + messages=[ + HumanMessage(content="Initial message"), + AIMessage(content="First new response"), + AIMessage(content="Second new response"), + ], + inner_state=MockInnerState(initial_message_count=1), + ) + + result = terminate_node(state) + + messages = result["uipath__agent_response_messages"] + assert len(messages) == 2 + assert messages[0]["role"] == "assistant" + assert "First new response" in str(messages[0]["contentParts"][0]["data"]) + assert messages[1]["role"] == "assistant" + assert "Second new response" in str(messages[1]["contentParts"][0]["data"]) + + def test_conversational_with_tool_calls_excludes_tool_results(self): + """Conversational mode should exclude tool results in output.""" + + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + + terminate_node = create_terminate_node( + response_schema=ResponseSchema, is_conversational=True + ) + + # Initial count is 1 + state = MockAgentGraphState( + messages=[ + HumanMessage(content="Initial"), + AIMessage( + content="Using tool", + tool_calls=[ + {"name": "test_tool", "args": {"param": "value"}, "id": "call1"} + ], + ), + ToolMessage(content="Tool result", tool_call_id="call1"), + ], + inner_state=MockInnerState(initial_message_count=1), + ) + + result = terminate_node(state) + + print(result) + + messages = result["uipath__agent_response_messages"] + # Should have AI message with tool calls, but NOT the ToolMessage + # The mapper with include_tool_results=False should only return AI messages + assert len(messages) == 1 + assert messages[0]["role"] == "assistant" + assert "Using tool" in str(messages[0]["contentParts"][0]["data"]) + # Verify tool calls are present in the message + assert len(messages[0]["toolCalls"]) == 1 + assert messages[0]["toolCalls"][0]["name"] == "test_tool" + assert messages[0]["toolCalls"][0]["input"] == {"param": "value"} def test_conversational_ignores_end_execution_tool(self): """Conversational mode should ignore END_EXECUTION tool calls.""" + + class ResponseSchema(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] + terminate_node = create_terminate_node( - response_schema=None, is_conversational=True + response_schema=ResponseSchema, is_conversational=True ) ai_message = AIMessage( content="Done", @@ -79,12 +191,19 @@ def test_conversational_ignores_end_execution_tool(self): } ], ) - state = MockAgentGraphState(messages=[ai_message]) + state = MockAgentGraphState( + messages=[HumanMessage(content="Initial"), ai_message], + inner_state=MockInnerState(initial_message_count=1), + ) - # Should return None, not process the tool call + # Should process normally, not treat as special result = terminate_node(state) - assert result is None + assert "uipath__agent_response_messages" in result + messages = result["uipath__agent_response_messages"] + assert len(messages) == 1 + assert messages[0]["role"] == "assistant" + assert "Done" in str(messages[0]["contentParts"][0]["data"]) class TestTerminateNodeNonConversational: diff --git a/tests/runtime/chat_message_mapper.py b/tests/runtime/chat_message_mapper.py index 554646b35..c1e7e5b85 100644 --- a/tests/runtime/chat_message_mapper.py +++ b/tests/runtime/chat_message_mapper.py @@ -1,5 +1,6 @@ """Tests for UiPathChatMessagesMapper.""" +import json from unittest.mock import AsyncMock, patch import pytest @@ -1352,3 +1353,261 @@ async def test_map_event_emits_message_end_after_last_tool_result(self): assert len(result2) == 2 # Tool call end + message end assert result2[0].tool_call is not None assert result2[1].end is not None + + +class TestMapLangChainMessagesToUiPathMessageData: + """Tests for map_langchain_messages_to_uipath_message_data_list static method.""" + + def test_converts_empty_messages_correctly(self): + """Should return empty list when input messages list is empty.""" + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + [] + ) + ) + + assert result == [] + + def test_converts_human_message_to_user_role(self): + """Should convert HumanMessage to user role message.""" + messages = [HumanMessage(content="Hello")] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages + ) + ) + + assert len(result) == 1 + assert result[0].role == "user" + assert len(result[0].content_parts) == 1 + assert result[0].content_parts[0].mime_type == "text/plain" + assert result[0].content_parts[0].data.inline == "Hello" + + def test_converts_ai_message_to_assistant_role(self): + """Should convert AIMessage to assistant role message.""" + messages = [AIMessage(content="Hi there")] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages + ) + ) + + assert len(result) == 1 + assert result[0].role == "assistant" + assert len(result[0].content_parts) == 1 + assert result[0].content_parts[0].mime_type == "text/markdown" + assert result[0].content_parts[0].data.inline == "Hi there" + + def test_converts_ai_message_with_tool_calls(self): + """Should include tool calls in converted AI message.""" + messages = [ + AIMessage( + content="Let me search", + tool_calls=[ + {"name": "search", "args": {"query": "test"}, "id": "call1"} + ], + ) + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=False + ) + ) + + assert len(result) == 1 + assert result[0].role == "assistant" + assert len(result[0].tool_calls) == 1 + assert result[0].tool_calls[0].name == "search" + assert result[0].tool_calls[0].input == {"query": "test"} + + def test_includes_tool_results_when_enabled(self): + """Should include tool results in tool calls when include_tool_results=True.""" + messages = [ + AIMessage( + content="Using tool", + tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], + ), + ToolMessage( + content='{"status": "success"}', tool_call_id="call1", status="success" + ), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=True + ) + ) + + assert len(result) == 1 # Only AI message, tool message merged in + assert result[0].role == "assistant" + assert len(result[0].tool_calls) == 1 + assert result[0].tool_calls[0].result is not None + assert result[0].tool_calls[0].result.output == {"status": "success"} + assert result[0].tool_calls[0].result.is_error is False + + def test_excludes_tool_results_when_disabled(self): + """Should exclude tool results when include_tool_results=False.""" + messages = [ + AIMessage( + content="Using tool", + tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], + ), + ToolMessage( + content='{"status": "success"}', tool_call_id="call1", status="success" + ), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=False + ) + ) + + assert len(result) == 1 + assert result[0].role == "assistant" + assert len(result[0].tool_calls) == 1 + # Tool call should not have result when include_tool_results=False + assert result[0].tool_calls[0].result is None + + def test_handles_tool_error_status(self): + """Should mark tool result as error when status is error.""" + messages = [ + AIMessage( + content="Trying tool", + tool_calls=[{"name": "failing_tool", "args": {}, "id": "call1"}], + ), + ToolMessage(content="Error occurred", tool_call_id="call1", status="error"), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=True + ) + ) + + assert len(result) == 1 + assert result[0].tool_calls[0].result is not None + assert result[0].tool_calls[0].result.is_error is True + assert result[0].tool_calls[0].result.output == "Error occurred" + + def test_parses_json_tool_results(self): + """Should parse JSON string results back to dict.""" + messages = [ + AIMessage( + content="Using tool", + tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], + ), + ToolMessage( + content='{"data": [1, 2, 3], "count": 3}', tool_call_id="call1" + ), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=True + ) + ) + + assert result[0].tool_calls[0].result.output == {"data": [1, 2, 3], "count": 3} + + def test_keeps_non_json_tool_results_as_string(self): + """Should keep non-JSON results as strings.""" + messages = [ + AIMessage( + content="Using tool", + tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], + ), + ToolMessage(content="plain text result", tool_call_id="call1"), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=True + ) + ) + + assert result[0].tool_calls[0].result.output == "plain text result" + + def test_handles_mixed_message_types(self): + """Should handle conversation with mixed message types including tools.""" + messages = [ + HumanMessage(content="Hello"), + AIMessage(content="Hi there"), + HumanMessage(content="Search for data"), + AIMessage( + content="Let me search", + tool_calls=[ + {"name": "search_tool", "args": {"query": "data"}, "id": "call1"} + ], + ), + ToolMessage(content='{"results": ["item1", "item2"]}', tool_call_id="call1"), + AIMessage(content="I found the data"), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages, include_tool_results=True + ) + ) + + # Should skip ToolMessages, only convert Human and AI messages + assert len(result) == 5 + assert result[0].role == "user" + assert result[1].role == "assistant" + assert result[2].role == "user" + assert result[3].role == "assistant" + assert len(result[3].tool_calls) == 1 + assert result[3].tool_calls[0].result is not None + assert result[4].role == "assistant" + + def test_handles_empty_message_list(self): + """Should return empty list for empty input.""" + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + [] + ) + ) + + assert result == [] + + def test_handles_empty_content_messages(self): + """Should handle messages with empty content.""" + messages = [ + HumanMessage(content=""), + AIMessage(content=""), + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages + ) + ) + + assert len(result) == 2 + # Empty content should result in no text content-parts + assert len(result[0].content_parts) == 0 + assert len(result[1].content_parts) == 0 + + def test_extracts_text_from_content_blocks(self): + """Should extract text from complex content block structures.""" + messages = [ + HumanMessage( + content=[ + {"type": "text", "text": "first part"}, + {"type": "text", "text": " second part"}, + ] + ) + ] + + result = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages + ) + ) + + assert len(result) == 1 + assert len(result[0].content_parts) == 1 + assert result[0].content_parts[0].data.inline == "first part second part" From 4f061fbf0302f360bdc886a1eb13a3e1dbc6cdae Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:16:55 -0600 Subject: [PATCH 4/8] chore: update versions --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3459d3f6..28e699d3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.5.74" +version = "0.5.75" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 2fe93f5f4..02111f54a 100644 --- a/uv.lock +++ b/uv.lock @@ -3323,7 +3323,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.5.74" +version = "0.5.75" source = { editable = "." } dependencies = [ { name = "httpx" }, From a7b0e5d41649fefc0833761478a06172289d5e8c Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:35:43 -0600 Subject: [PATCH 5/8] chore: improve comment --- src/uipath_langchain/agent/react/terminate_node.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index dcb143a24..dfcba3528 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -61,8 +61,8 @@ def _handle_end_conversational( converted_messages: list[UiPathConversationMessageData] = [] - # For the agent-output messages, don't include tool-results. Just include agent's response choices (LLM outputs and tool-calls). - # This is for simpler agent output and because evaluations don't check for tool-results. + # For the agent-output messages, don't include tool-results. Just include agent's LLM outputs and tool-calls + inputs. + # This is primarily since evaluations don't check for tool-results; this output represents the agent's actual choices rather than tool-results. if new_messages: converted_messages = ( UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( From 47512d650e4b4164adea806b32ae24abe883c56a Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:38:47 -0600 Subject: [PATCH 6/8] fix: mypy build --- tests/agent/react/test_init_node.py | 8 +++----- tests/runtime/chat_message_mapper.py | 28 +++++++++++++++++----------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/agent/react/test_init_node.py b/tests/agent/react/test_init_node.py index feeeddafc..23e04f307 100644 --- a/tests/agent/react/test_init_node.py +++ b/tests/agent/react/test_init_node.py @@ -266,12 +266,10 @@ def test_initial_message_count_in_non_conversational_mode(self): def test_initial_message_count_in_conversational_mode(self): """Conversational mode should set initial_message_count based on Overwrite.""" - messages: list[SystemMessage | HumanMessage] = [ + messages = [ SystemMessage(content="System"), HumanMessage(content="Query"), - AIMessage(content="Response"), - HumanMessage(content="Query"), - AIMessage(content="Response"), + HumanMessage(content="Query2"), ] init_node = create_init_node( messages, input_schema=None, is_conversational=True @@ -281,4 +279,4 @@ def test_initial_message_count_in_conversational_mode(self): result = init_node(state) assert "initial_message_count" in result["inner_state"] - assert result["inner_state"]["initial_message_count"] == 5 + assert result["inner_state"]["initial_message_count"] == 3 diff --git a/tests/runtime/chat_message_mapper.py b/tests/runtime/chat_message_mapper.py index c1e7e5b85..994f1d70c 100644 --- a/tests/runtime/chat_message_mapper.py +++ b/tests/runtime/chat_message_mapper.py @@ -7,6 +7,7 @@ from langchain_core.messages import ( AIMessage, AIMessageChunk, + AnyMessage, HumanMessage, SystemMessage, ToolMessage, @@ -1370,7 +1371,7 @@ def test_converts_empty_messages_correctly(self): def test_converts_human_message_to_user_role(self): """Should convert HumanMessage to user role message.""" - messages = [HumanMessage(content="Hello")] + messages: list[AnyMessage] = [HumanMessage(content="Hello")] result = ( UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( @@ -1382,11 +1383,12 @@ def test_converts_human_message_to_user_role(self): assert result[0].role == "user" assert len(result[0].content_parts) == 1 assert result[0].content_parts[0].mime_type == "text/plain" + assert isinstance(result[0].content_parts[0].data, UiPathInlineValue) assert result[0].content_parts[0].data.inline == "Hello" def test_converts_ai_message_to_assistant_role(self): """Should convert AIMessage to assistant role message.""" - messages = [AIMessage(content="Hi there")] + messages: list[AnyMessage] = [AIMessage(content="Hi there")] result = ( UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( @@ -1398,11 +1400,12 @@ def test_converts_ai_message_to_assistant_role(self): assert result[0].role == "assistant" assert len(result[0].content_parts) == 1 assert result[0].content_parts[0].mime_type == "text/markdown" + assert isinstance(result[0].content_parts[0].data, UiPathInlineValue) assert result[0].content_parts[0].data.inline == "Hi there" def test_converts_ai_message_with_tool_calls(self): """Should include tool calls in converted AI message.""" - messages = [ + messages: list[AnyMessage] = [ AIMessage( content="Let me search", tool_calls=[ @@ -1425,7 +1428,7 @@ def test_converts_ai_message_with_tool_calls(self): def test_includes_tool_results_when_enabled(self): """Should include tool results in tool calls when include_tool_results=True.""" - messages = [ + messages: list[AnyMessage] = [ AIMessage( content="Using tool", tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], @@ -1450,7 +1453,7 @@ def test_includes_tool_results_when_enabled(self): def test_excludes_tool_results_when_disabled(self): """Should exclude tool results when include_tool_results=False.""" - messages = [ + messages: list[AnyMessage] = [ AIMessage( content="Using tool", tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], @@ -1474,7 +1477,7 @@ def test_excludes_tool_results_when_disabled(self): def test_handles_tool_error_status(self): """Should mark tool result as error when status is error.""" - messages = [ + messages: list[AnyMessage] = [ AIMessage( content="Trying tool", tool_calls=[{"name": "failing_tool", "args": {}, "id": "call1"}], @@ -1495,7 +1498,7 @@ def test_handles_tool_error_status(self): def test_parses_json_tool_results(self): """Should parse JSON string results back to dict.""" - messages = [ + messages: list[AnyMessage] = [ AIMessage( content="Using tool", tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], @@ -1511,11 +1514,12 @@ def test_parses_json_tool_results(self): ) ) + assert result[0].tool_calls[0].result is not None assert result[0].tool_calls[0].result.output == {"data": [1, 2, 3], "count": 3} def test_keeps_non_json_tool_results_as_string(self): """Should keep non-JSON results as strings.""" - messages = [ + messages: list[AnyMessage] = [ AIMessage( content="Using tool", tool_calls=[{"name": "test_tool", "args": {}, "id": "call1"}], @@ -1529,11 +1533,12 @@ def test_keeps_non_json_tool_results_as_string(self): ) ) + assert result[0].tool_calls[0].result is not None assert result[0].tool_calls[0].result.output == "plain text result" def test_handles_mixed_message_types(self): """Should handle conversation with mixed message types including tools.""" - messages = [ + messages: list[AnyMessage] = [ HumanMessage(content="Hello"), AIMessage(content="Hi there"), HumanMessage(content="Search for data"), @@ -1575,7 +1580,7 @@ def test_handles_empty_message_list(self): def test_handles_empty_content_messages(self): """Should handle messages with empty content.""" - messages = [ + messages: list[AnyMessage] = [ HumanMessage(content=""), AIMessage(content=""), ] @@ -1593,7 +1598,7 @@ def test_handles_empty_content_messages(self): def test_extracts_text_from_content_blocks(self): """Should extract text from complex content block structures.""" - messages = [ + messages: list[AnyMessage] = [ HumanMessage( content=[ {"type": "text", "text": "first part"}, @@ -1610,4 +1615,5 @@ def test_extracts_text_from_content_blocks(self): assert len(result) == 1 assert len(result[0].content_parts) == 1 + assert isinstance(result[0].content_parts[0].data, UiPathInlineValue) assert result[0].content_parts[0].data.inline == "first part second part" From a8dafbc1ef7844319aace359a86495c8de4dbb66 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:39:21 -0600 Subject: [PATCH 7/8] fix: mypy tests again --- tests/agent/react/test_init_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agent/react/test_init_node.py b/tests/agent/react/test_init_node.py index 23e04f307..2da8ac35d 100644 --- a/tests/agent/react/test_init_node.py +++ b/tests/agent/react/test_init_node.py @@ -266,7 +266,7 @@ def test_initial_message_count_in_non_conversational_mode(self): def test_initial_message_count_in_conversational_mode(self): """Conversational mode should set initial_message_count based on Overwrite.""" - messages = [ + messages: list[SystemMessage | HumanMessage] = [ SystemMessage(content="System"), HumanMessage(content="Query"), HumanMessage(content="Query2"), From c23009bb923775d46bca82ff3c9d9796422102f3 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:41:57 -0600 Subject: [PATCH 8/8] fix: formatting --- tests/agent/react/test_init_node.py | 2 +- tests/agent/react/test_terminate_node.py | 2 +- tests/runtime/chat_message_mapper.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/agent/react/test_init_node.py b/tests/agent/react/test_init_node.py index 2da8ac35d..b9c9919f8 100644 --- a/tests/agent/react/test_init_node.py +++ b/tests/agent/react/test_init_node.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage from langgraph.types import Overwrite from pydantic import BaseModel diff --git a/tests/agent/react/test_terminate_node.py b/tests/agent/react/test_terminate_node.py index 808d36c42..088f5d1bc 100644 --- a/tests/agent/react/test_terminate_node.py +++ b/tests/agent/react/test_terminate_node.py @@ -4,9 +4,9 @@ import pytest from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -from uipath.core.chat import UiPathConversationMessageData from pydantic import BaseModel from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL +from uipath.core.chat import UiPathConversationMessageData from uipath_langchain.agent.exceptions import ( AgentRuntimeError, diff --git a/tests/runtime/chat_message_mapper.py b/tests/runtime/chat_message_mapper.py index 994f1d70c..2f3c7f332 100644 --- a/tests/runtime/chat_message_mapper.py +++ b/tests/runtime/chat_message_mapper.py @@ -1,6 +1,5 @@ """Tests for UiPathChatMessagesMapper.""" -import json from unittest.mock import AsyncMock, patch import pytest @@ -1548,7 +1547,9 @@ def test_handles_mixed_message_types(self): {"name": "search_tool", "args": {"query": "data"}, "id": "call1"} ], ), - ToolMessage(content='{"results": ["item1", "item2"]}', tool_call_id="call1"), + ToolMessage( + content='{"results": ["item1", "item2"]}', tool_call_id="call1" + ), AIMessage(content="I found the data"), ]