Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/uipath_langchain/agent/react/init_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ def graph_state_init(state: Any) -> Any:
)
job_attachments_dict.update(message_attachments)

# 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,
},
}

Expand Down
53 changes: 51 additions & 2 deletions src/uipath_langchain/agent/react/terminate_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 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(
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(by_alias=True)


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 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(
Expand Down
1 change: 1 addition & 0 deletions src/uipath_langchain/agent/react/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}


Expand Down
136 changes: 134 additions & 2 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
AnyMessage,
BaseMessage,
ContentBlock,
HumanMessage,
Expand All @@ -19,15 +20,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,
UiPathExternalValue,
UiPathInlineValue,
Expand Down Expand Up @@ -55,7 +60,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
Expand Down Expand Up @@ -223,7 +229,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,
Expand Down Expand Up @@ -544,5 +550,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"]
34 changes: 34 additions & 0 deletions tests/agent/react/test_init_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,37 @@ 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"),
HumanMessage(content="Query2"),
]
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"] == 3
Loading