From 75d1ab2cc8e17f509a5a980dc044450e1c75ac58 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 14 Jul 2026 15:03:00 +0300 Subject: [PATCH 1/2] fix: harden conversational advanced message handling --- pyproject.toml | 2 +- src/uipath_langchain/agent/advanced/agent.py | 29 ++++++------ src/uipath_langchain/agent/advanced/types.py | 8 ++-- src/uipath_langchain/runtime/messages.py | 16 +++++-- .../test_chat_message_mapper_workspace.py | 44 ++++++++++++++++++- uv.lock | 2 +- 6 files changed, 76 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bec22e3d9..1a5adfd4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.14.7" +version = "0.14.8" 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/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 4baaf1f0b..a013f25cf 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -14,10 +14,13 @@ from langchain_core.tools import BaseTool from langgraph.graph import END, START from langgraph.graph.state import CompiledStateGraph, StateGraph -from pydantic import BaseModel +from pydantic import BaseModel, Field from uipath.core.chat import UiPathConversationMessageData +from uipath.runtime.errors import UiPathErrorCategory +from uipath_langchain.agent.exceptions import AgentRuntimeError, AgentRuntimeErrorCode from uipath_langchain.agent.react.job_attachments import get_job_attachment_paths +from uipath_langchain.runtime.messages import UiPathChatMessagesMapper from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState from .utils import ( @@ -129,16 +132,7 @@ def create_conversational_advanced_agent_graph( system_prompt: str, backend: BackendProtocol | BackendFactory | None, ) -> StateGraph[Any, Any, Any, Any]: - """Wrap the advanced agent in a parent graph that speaks the conversational contract. - - Conversational agents receive the full conversation history in the - ``messages`` input each exchange and must output the newly produced - messages as ``uipath__agent_response_messages``. The deepagent already - operates on ``messages``, so the wrapper only records the incoming history - size and maps the new messages to the conversational output field. - """ - # deferred: avoids a circular import (runtime.messages imports agent modules) - from uipath_langchain.runtime.messages import UiPathChatMessagesMapper + """Map conversation history and new deepagent messages to the CAS contract.""" memory_sources = ( [MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else [] @@ -153,7 +147,9 @@ def create_conversational_advanced_agent_graph( ) class ConversationalAdvancedAgentOutput(BaseModel): - uipath__agent_response_messages: list[UiPathConversationMessageData] = [] + uipath__agent_response_messages: list[UiPathConversationMessageData] = Field( + default_factory=list + ) def capture_exchange_start( state: ConversationalAdvancedAgentGraphState, @@ -163,7 +159,14 @@ def capture_exchange_start( def transform_output( state: ConversationalAdvancedAgentGraphState, ) -> dict[str, Any]: - initial_count = state.initial_message_count or 0 + if state.initial_message_count is None: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.STATE_ERROR, + title="Conversation state is incomplete", + detail="The initial message count was not captured before execution.", + category=UiPathErrorCategory.SYSTEM, + ) + initial_count = state.initial_message_count new_messages = state.messages[initial_count:] converted = ( UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( diff --git a/src/uipath_langchain/agent/advanced/types.py b/src/uipath_langchain/agent/advanced/types.py index 929981fc9..6ea49d032 100644 --- a/src/uipath_langchain/agent/advanced/types.py +++ b/src/uipath_langchain/agent/advanced/types.py @@ -4,18 +4,18 @@ from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages -from pydantic import BaseModel +from pydantic import BaseModel, Field class AdvancedAgentGraphState(BaseModel): """Graph state for the advanced agent wrapper.""" - messages: Annotated[list[AnyMessage], add_messages] = [] - structured_response: dict[str, Any] = {} + messages: Annotated[list[AnyMessage], add_messages] = Field(default_factory=list) + structured_response: dict[str, Any] = Field(default_factory=dict) class ConversationalAdvancedAgentGraphState(BaseModel): """Graph state for the conversational advanced agent wrapper.""" - messages: Annotated[list[AnyMessage], add_messages] = [] + messages: Annotated[list[AnyMessage], add_messages] = Field(default_factory=list) initial_message_count: int | None = None diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index b6dab7a3b..f5ce95bc7 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -188,10 +188,18 @@ def _map_messages_internal( ) ) elif isinstance(data, UiPathExternalValue): - if uipath_message.role == "assistant": - # Workspace files persisted by the advanced runtime - # (hydrated into the file backend before the graph - # runs); they are not attachments for the LLM. + part_metadata = getattr( + uipath_content_part, "metadata", None + ) + has_workspace_metadata = ( + isinstance(part_metadata, dict) + and part_metadata.get("fileKind") == "workspace" + ) + is_workspace_file = ( + has_workspace_metadata + or uipath_content_part.content_part_id.startswith("ws-") + ) + if uipath_message.role == "assistant" and is_workspace_file: continue attachment_id = self.parse_attachment_id_from_content_part_uri( data.uri diff --git a/tests/runtime/test_chat_message_mapper_workspace.py b/tests/runtime/test_chat_message_mapper_workspace.py index 065f37af1..6e9935661 100644 --- a/tests/runtime/test_chat_message_mapper_workspace.py +++ b/tests/runtime/test_chat_message_mapper_workspace.py @@ -13,12 +13,18 @@ CAS_URI = "urn:uipath:cas:file:orchestrator:a940a416-b97b-4146-3089-08de5f4d0a87" -def _file_part(part_id: str, name: str) -> UiPathConversationContentPart: +def _file_part( + part_id: str, + name: str, + *, + metadata: dict[str, str] | None = None, +) -> UiPathConversationContentPart: return UiPathConversationContentPart( content_part_id=part_id, mime_type="text/markdown", data=UiPathExternalValue(uri=CAS_URI), name=name, + metadata=metadata, citations=[], ) @@ -35,7 +41,7 @@ def test_assistant_file_parts_are_skipped() -> None: data=UiPathInlineValue(inline="done, see the plan"), citations=[], ), - _file_part("p2", "plan/todo.md"), + _file_part("p2", "plan/todo.md", metadata={"fileKind": "workspace"}), ], tool_calls=[], ) @@ -65,3 +71,37 @@ def test_user_file_parts_still_produce_attachments() -> None: user = result[0] assert isinstance(user, HumanMessage) assert user.additional_kwargs["attachments"][0]["full_name"] == "report.pdf" + + +def test_ordinary_assistant_file_parts_still_produce_attachments() -> None: + mapper = UiPathChatMessagesMapper("test-runtime", None) + message = UiPathConversationMessage( + message_id="a1", + role="assistant", + content_parts=[_file_part("p1", "report.pdf")], + tool_calls=[], + ) + + result = mapper.map_messages([message]) + + assert len(result) == 1 + assistant = result[0] + assert isinstance(assistant, AIMessage) + assert assistant.additional_kwargs["attachments"][0]["full_name"] == "report.pdf" + + +def test_workspace_part_id_is_hidden_without_metadata() -> None: + mapper = UiPathChatMessagesMapper("test-runtime", None) + message = UiPathConversationMessage( + message_id="a1", + role="assistant", + content_parts=[_file_part("ws-a1-0", "plan.md")], + tool_calls=[], + ) + + result = mapper.map_messages([message]) + + assert len(result) == 1 + assistant = result[0] + assert isinstance(assistant, AIMessage) + assert "attachments" not in assistant.additional_kwargs diff --git a/uv.lock b/uv.lock index 8c051d23c..f733e2d38 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.7" +version = "0.14.8" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From cf67632bf670da9b98229238e1ded136e62756f2 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 14 Jul 2026 15:38:11 +0300 Subject: [PATCH 2/2] fix: keep workspace mapping dependency-safe --- src/uipath_langchain/_client_side_tool_types.py | 13 +++++++++++++ .../agent/tools/client_side_tool.py | 10 ++++------ src/uipath_langchain/runtime/messages.py | 6 ++---- src/uipath_langchain/runtime/runtime.py | 2 +- tests/runtime/test_chat_message_mapper_workspace.py | 2 +- 5 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 src/uipath_langchain/_client_side_tool_types.py diff --git a/src/uipath_langchain/_client_side_tool_types.py b/src/uipath_langchain/_client_side_tool_types.py new file mode 100644 index 000000000..9788af835 --- /dev/null +++ b/src/uipath_langchain/_client_side_tool_types.py @@ -0,0 +1,13 @@ +"""Shared client-side tool types.""" + +from typing import Any, TypedDict + + +class ClientSideToolInfo(TypedDict): + """Schemas exposed for a client-side tool.""" + + input_schema: dict[str, Any] | None + output_schema: dict[str, Any] | None + + +__all__ = ["ClientSideToolInfo"] diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index 6471c7d7e..e686c0cc1 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -2,13 +2,16 @@ import json from contextvars import ContextVar -from typing import Annotated, Any, TypedDict +from typing import Annotated, Any from langchain_core.messages import ToolMessage from langchain_core.tools import InjectedToolCallId, StructuredTool from uipath.agent.models.agent import AgentClientSideToolResourceConfig from uipath.eval.mocks import mockable +from uipath_langchain._client_side_tool_types import ( + ClientSideToolInfo as ClientSideToolInfo, +) from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model as create_model_from_schema, @@ -26,11 +29,6 @@ UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY = "uipath__client_side_tools" -class ClientSideToolInfo(TypedDict): - input_schema: dict[str, Any] | None - output_schema: dict[str, Any] | None - - def apply_tool_filter( declared_tools: list[str | dict[str, Any]], agent_tools: dict[str, ClientSideToolInfo], diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index f5ce95bc7..f92422fce 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -40,7 +40,7 @@ ) from uipath.runtime import UiPathRuntimeStorageProtocol -from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo +from uipath_langchain._client_side_tool_types import ClientSideToolInfo from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from ._citations import ( @@ -188,9 +188,7 @@ def _map_messages_internal( ) ) elif isinstance(data, UiPathExternalValue): - part_metadata = getattr( - uipath_content_part, "metadata", None - ) + part_metadata = getattr(uipath_content_part, "metadata", None) has_workspace_metadata = ( isinstance(part_metadata, dict) and part_metadata.get("fileKind") == "workspace" diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 04657b1e7..885ededce 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -32,7 +32,7 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema -from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo +from uipath_langchain._client_side_tool_types import ClientSideToolInfo from uipath_langchain.chat.hitl import ( IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, get_confirmation_schema, diff --git a/tests/runtime/test_chat_message_mapper_workspace.py b/tests/runtime/test_chat_message_mapper_workspace.py index 6e9935661..f101d710a 100644 --- a/tests/runtime/test_chat_message_mapper_workspace.py +++ b/tests/runtime/test_chat_message_mapper_workspace.py @@ -41,7 +41,7 @@ def test_assistant_file_parts_are_skipped() -> None: data=UiPathInlineValue(inline="done, see the plan"), citations=[], ), - _file_part("p2", "plan/todo.md", metadata={"fileKind": "workspace"}), + _file_part("ws-a1-0", "plan/todo.md", metadata={"fileKind": "workspace"}), ], tool_calls=[], )