Skip to content
Merged
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.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"
Expand Down
10 changes: 8 additions & 2 deletions src/uipath_langchain/agent/advanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
]
69 changes: 68 additions & 1 deletion src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions src/uipath_langchain/agent/advanced/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
86 changes: 86 additions & 0 deletions tests/agent/advanced/test_conversational_advanced_agent_graph.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
radu-mocanu marked this conversation as resolved.
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
67 changes: 67 additions & 0 deletions tests/runtime/test_chat_message_mapper_workspace.py
Original file line number Diff line number Diff line change
@@ -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 "<uip:attachments>" 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"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading