diff --git a/pyproject.toml b/pyproject.toml index a2730d515..fe798e6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.13.24" +version = "0.13.25" 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/__init__.py b/src/uipath_langchain/agent/advanced/__init__.py index f556c6db6..1605996aa 100644 --- a/src/uipath_langchain/agent/advanced/__init__.py +++ b/src/uipath_langchain/agent/advanced/__init__.py @@ -4,8 +4,12 @@ from deepagents.backends import BackendProtocol, FilesystemBackend from deepagents.backends.protocol import BackendFactory -from .agent import create_advanced_agent, create_advanced_agent_graph -from .types import AdvancedAgentGraphState +from .agent import ( + create_advanced_agent, + create_advanced_agent_graph, + create_conversational_advanced_agent_graph, +) +from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState from .utils import ( MEMORY_DIR_NAME, MEMORY_INDEX_FILENAME, @@ -21,9 +25,11 @@ "BackendFactory", "BackendProtocol", "CompiledSubAgent", + "ConversationalAdvancedAgentGraphState", "FilesystemBackend", "SubAgent", "create_advanced_agent", "create_advanced_agent_graph", + "create_conversational_advanced_agent_graph", "create_state_with_input", ] diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 775e295e9..4baaf1f0b 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -15,10 +15,11 @@ from langgraph.graph import END, START from langgraph.graph.state import CompiledStateGraph, StateGraph from pydantic import BaseModel +from uipath.core.chat import UiPathConversationMessageData from uipath_langchain.agent.react.job_attachments import get_job_attachment_paths -from .types import AdvancedAgentGraphState +from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState from .utils import ( MEMORY_INDEX_VIRTUAL_PATH, create_state_with_input, @@ -120,3 +121,69 @@ def transform_output(state: BaseModel) -> dict[str, Any]: wrapper.add_edge("transform_output", END) return wrapper + + +def create_conversational_advanced_agent_graph( + model: BaseChatModel, + tools: Sequence[BaseTool], + 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 + + memory_sources = ( + [MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else [] + ) + + inner_graph = create_advanced_agent( + model=model, + tools=tools, + system_prompt=system_prompt, + backend=backend, + memory=memory_sources, + ) + + class ConversationalAdvancedAgentOutput(BaseModel): + uipath__agent_response_messages: list[UiPathConversationMessageData] = [] + + def capture_exchange_start( + state: ConversationalAdvancedAgentGraphState, + ) -> dict[str, Any]: + return {"initial_message_count": len(state.messages)} + + def transform_output( + state: ConversationalAdvancedAgentGraphState, + ) -> dict[str, Any]: + initial_count = state.initial_message_count or 0 + new_messages = state.messages[initial_count:] + converted = ( + UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list( + messages=new_messages, include_tool_results=False + ) + if new_messages + else [] + ) + return {"uipath__agent_response_messages": converted} + + wrapper: StateGraph[Any, Any, Any, Any] = StateGraph( + ConversationalAdvancedAgentGraphState, + output_schema=ConversationalAdvancedAgentOutput, + ) + wrapper.add_node("capture_exchange_start", capture_exchange_start) + wrapper.add_node("advanced_agent", inner_graph) + wrapper.add_node("transform_output", transform_output) + wrapper.add_edge(START, "capture_exchange_start") + wrapper.add_edge("capture_exchange_start", "advanced_agent") + wrapper.add_edge("advanced_agent", "transform_output") + wrapper.add_edge("transform_output", END) + + return wrapper diff --git a/src/uipath_langchain/agent/advanced/types.py b/src/uipath_langchain/agent/advanced/types.py index 7d6b64e47..929981fc9 100644 --- a/src/uipath_langchain/agent/advanced/types.py +++ b/src/uipath_langchain/agent/advanced/types.py @@ -12,3 +12,10 @@ class AdvancedAgentGraphState(BaseModel): messages: Annotated[list[AnyMessage], add_messages] = [] structured_response: dict[str, Any] = {} + + +class ConversationalAdvancedAgentGraphState(BaseModel): + """Graph state for the conversational advanced agent wrapper.""" + + messages: Annotated[list[AnyMessage], add_messages] = [] + initial_message_count: int | None = None diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index ca2982589..15481d347 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -188,6 +188,11 @@ 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. + continue attachment_id = self.parse_attachment_id_from_content_part_uri( data.uri ) diff --git a/tests/agent/advanced/test_conversational_advanced_agent_graph.py b/tests/agent/advanced/test_conversational_advanced_agent_graph.py new file mode 100644 index 000000000..7817fe8a3 --- /dev/null +++ b/tests/agent/advanced/test_conversational_advanced_agent_graph.py @@ -0,0 +1,86 @@ +"""Tests for the conversational advanced agent wrapper builder.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.graph import END, START, StateGraph + +from uipath_langchain.agent.advanced.agent import ( + create_conversational_advanced_agent_graph, +) +from uipath_langchain.agent.advanced.types import ( + ConversationalAdvancedAgentGraphState, +) + + +def _mock_model() -> MagicMock: + model = MagicMock(spec=BaseChatModel) + model.profile = None + return model + + +def _fake_inner_agent() -> Any: + """A stand-in deepagent that appends one AI message.""" + + def respond(state: ConversationalAdvancedAgentGraphState) -> dict[str, Any]: + return {"messages": [AIMessage(content="here is my plan", id="ai-1")]} + + builder: StateGraph[Any, Any, Any, Any] = StateGraph( + ConversationalAdvancedAgentGraphState + ) + builder.add_node("respond", respond) + builder.add_edge(START, "respond") + builder.add_edge("respond", END) + return builder.compile() + + +def test_wrapper_graph_has_conversational_nodes() -> None: + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), tools=[], system_prompt="sys", backend=None + ) + assert { + "capture_exchange_start", + "advanced_agent", + "transform_output", + } <= set(graph.nodes) + + +@pytest.mark.asyncio +async def test_outputs_only_new_messages_as_response_messages() -> None: + with patch( + "uipath_langchain.agent.advanced.agent.create_advanced_agent", + return_value=_fake_inner_agent(), + ): + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), tools=[], system_prompt="sys", backend=None + ).compile() + + history = [ + HumanMessage(content="hi", id="u1"), + AIMessage(content="hello", id="a1"), + HumanMessage(content="make a plan", id="u2"), + ] + result = await graph.ainvoke({"messages": history}) + + response_messages = result["uipath__agent_response_messages"] + assert len(response_messages) == 1 + assert response_messages[0].role == "assistant" + assert response_messages[0].content_parts[0].data.inline == "here is my plan" + + +@pytest.mark.asyncio +async def test_empty_history_still_produces_response() -> None: + with patch( + "uipath_langchain.agent.advanced.agent.create_advanced_agent", + return_value=_fake_inner_agent(), + ): + graph = create_conversational_advanced_agent_graph( + model=_mock_model(), tools=[], system_prompt="sys", backend=None + ).compile() + + result = await graph.ainvoke({"messages": [HumanMessage(content="hi", id="u1")]}) + + assert len(result["uipath__agent_response_messages"]) == 1 diff --git a/tests/runtime/test_chat_message_mapper_workspace.py b/tests/runtime/test_chat_message_mapper_workspace.py new file mode 100644 index 000000000..065f37af1 --- /dev/null +++ b/tests/runtime/test_chat_message_mapper_workspace.py @@ -0,0 +1,67 @@ +"""Assistant-message workspace file content-parts must be hidden from the LLM.""" + +from langchain_core.messages import AIMessage, HumanMessage +from uipath.core.chat import ( + UiPathConversationContentPart, + UiPathConversationMessage, + UiPathExternalValue, + UiPathInlineValue, +) + +from uipath_langchain.runtime.messages import UiPathChatMessagesMapper + +CAS_URI = "urn:uipath:cas:file:orchestrator:a940a416-b97b-4146-3089-08de5f4d0a87" + + +def _file_part(part_id: str, name: str) -> UiPathConversationContentPart: + return UiPathConversationContentPart( + content_part_id=part_id, + mime_type="text/markdown", + data=UiPathExternalValue(uri=CAS_URI), + name=name, + citations=[], + ) + + +def test_assistant_file_parts_are_skipped() -> None: + mapper = UiPathChatMessagesMapper("test-runtime", None) + message = UiPathConversationMessage( + message_id="a1", + role="assistant", + content_parts=[ + UiPathConversationContentPart( + content_part_id="p1", + mime_type="text/plain", + data=UiPathInlineValue(inline="done, see the plan"), + citations=[], + ), + _file_part("p2", "plan/todo.md"), + ], + tool_calls=[], + ) + + result = mapper.map_messages([message]) + + assert len(result) == 1 + ai = result[0] + assert isinstance(ai, AIMessage) + assert "" not in ai.content + assert "attachments" not in ai.additional_kwargs + assert "done, see the plan" in ai.content + + +def test_user_file_parts_still_produce_attachments() -> None: + mapper = UiPathChatMessagesMapper("test-runtime", None) + message = UiPathConversationMessage( + message_id="u1", + role="user", + content_parts=[_file_part("p1", "report.pdf")], + tool_calls=[], + ) + + result = mapper.map_messages([message]) + + assert len(result) == 1 + user = result[0] + assert isinstance(user, HumanMessage) + assert user.additional_kwargs["attachments"][0]["full_name"] == "report.pdf" diff --git a/uv.lock b/uv.lock index 6f5bdf744..f2b85e30b 100644 --- a/uv.lock +++ b/uv.lock @@ -4477,7 +4477,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.13.24" +version = "0.13.25" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },