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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[project]
name = "uipath-langchain"
version = "0.14.2"
version = "0.14.3"
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"
dependencies = [
"uipath>=2.13.5, <2.14.0",
"uipath>=2.13.7, <2.14.0",
"uipath-core>=0.5.29, <0.6.0",
"uipath-platform>=0.2.4, <0.3.0",
"uipath-runtime>=0.12.1, <0.13.0",
Expand Down
34 changes: 31 additions & 3 deletions src/uipath_langchain/agent/react/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from ...runtime._citations import cas_deep_rag_citation_wrapper
from ..guardrails.actions import GuardrailAction
from ..tools.structured_tool_with_output_type import StructuredToolWithOutputType
from .conversational_output_node import (
create_conversational_output_node,
)
from .guardrails.guardrails_subgraph import (
create_agent_init_guardrails_subgraph,
create_agent_terminate_guardrails_subgraph,
Expand Down Expand Up @@ -42,7 +45,10 @@
AgentGraphState,
MemoryConfig,
)
from .utils import create_state_with_input
from .utils import (
create_state_with_input,
has_custom_conversational_output_fields,
)

InputT = TypeVar("InputT", bound=BaseModel)
OutputT = TypeVar("OutputT", bound=BaseModel)
Expand Down Expand Up @@ -124,7 +130,14 @@ def create_agent(
tool_nodes_with_guardrails
)

terminate_node = create_terminate_node(output_schema, config.is_conversational)
with_conversational_output_node = (
config.is_conversational
and has_custom_conversational_output_fields(output_schema)
)

terminate_node = create_terminate_node(
output_schema, config.is_conversational, with_conversational_output_node
)

CompleteAgentGraphState = create_state_with_input(
input_schema if input_schema is not None else BaseModel
Expand All @@ -150,6 +163,16 @@ def create_agent(
)
builder.add_node(AgentGraphNode.TERMINATE, terminate_with_guardrails_subgraph)

if with_conversational_output_node and output_schema is not None:
builder.add_node(
AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT,
create_conversational_output_node(model, output_schema),
)
builder.add_edge(
AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT,
AgentGraphNode.TERMINATE,
)

if memory:
memory_recall = create_memory_recall_node(memory, input_schema=input_schema)
builder.add_node(AgentGraphNode.MEMORY_RECALL, memory_recall)
Expand Down Expand Up @@ -182,7 +205,12 @@ def create_agent(
*tool_node_names,
AgentGraphNode.TERMINATE,
]
route_agent = create_route_agent_conversational(valid_targets=target_node_names)
if with_conversational_output_node:
target_node_names.append(AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT)
route_agent = create_route_agent_conversational(
valid_targets=target_node_names,
with_generate_output_node=with_conversational_output_node,
)
else:
target_node_names = [
AgentGraphNode.AGENT,
Expand Down
2 changes: 2 additions & 0 deletions src/uipath_langchain/agent/react/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES = 0
DEFAULT_MAX_LLM_MESSAGES = 25

UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD = "uipath__agent_response_messages"
101 changes: 101 additions & 0 deletions src/uipath_langchain/agent/react/conversational_output_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""GENERATE_CONVERSATIONAL_OUTPUT node for the Agent graph.

This intermediate node runs after AGENT for conversational agents whose
output schema declares custom fields beyond `uipath__agent_response_messages`.
It performs a focused LLM call with only the `set_conversational_output`
tool bound and `tool_choice="any"` to extract the structured output for the turn.
"""

from typing import TypeVar

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables.config import var_child_runnable_config
from pydantic import BaseModel
from uipath.agent.react.conversational_prompts import (
get_generate_output_prompt,
)
from uipath.llm_client import UiPathAPIError, UiPathError
from uipath.llm_client.utils.exceptions import as_uipath_error
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.chat.handlers import get_payload_handler

from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from ..exceptions.licensing import raise_for_provider_http_error
from ..exceptions.llm import raise_for_llm_client_error
from ..tools.utils import config_without_streaming
from .tools.tools import create_set_conversational_output_tool
from .types import AgentGraphState

StateT = TypeVar("StateT", bound=AgentGraphState)


def create_conversational_output_node(
model: BaseChatModel,
agent_output_schema: type[BaseModel],
):
"""Build the focused structured-output extraction node.

Args:
model: The chat model to invoke for the extraction call. Reused from
the AGENT loop; rebinding is stateless.
agent_output_schema: The agent's declared output schema. Used to
construct the `set_conversational_output` tool with the
LLM-fillable fields (`uipath__agent_response_messages` stripped).
"""
set_conversational_output_tool = create_set_conversational_output_tool(
agent_output_schema
)
# Disable streaming on this internal LLM call
non_streaming_model = model.model_copy(update={"disable_streaming": True})
payload_handler = get_payload_handler(non_streaming_model)
binding_kwargs = payload_handler.get_tool_binding_kwargs(
tools=[set_conversational_output_tool],
tool_choice="any",
parallel_tool_calls=False,
)
llm = non_streaming_model.bind_tools(
[set_conversational_output_tool], **binding_kwargs
)
output_prompt = get_generate_output_prompt()

async def conversational_output_node(state: StateT):
# The appended HumanMessage stays local to this LLM call — only the
# response is returned to state, so the framework instruction never
# enters the persisted conversation history.
messages = [*state.messages, HumanMessage(content=output_prompt)]
config = config_without_streaming(var_child_runnable_config.get(None))

try:
response = await llm.ainvoke(messages, config=config)
except UiPathAPIError as e:
# New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly.
raise_for_provider_http_error(e)
except UiPathError as e:
raise_for_llm_client_error(e)
raise
except Exception as e:
# Legacy in-repo clients (use_new_llm_clients=False) raise raw provider SDK exceptions.
# Normalize via as_uipath_error and apply the same mapping when the error is HTTP-shaped; non-HTTP errors propagate.
uipath_error = as_uipath_error(e)
if isinstance(uipath_error, UiPathAPIError):
raise_for_provider_http_error(uipath_error)
raise

if not isinstance(response, AIMessage):
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.LLM_INVALID_RESPONSE,
title=f"Structured-output LLM returned {type(response).__name__} invalid response.",
detail=(
"The language model returned an unexpected response type. "
"If you are using a BYOM configuration, verify your model deployment.",
),
category=UiPathErrorCategory.SYSTEM,
)

payload_handler.check_stop_reason(response)

return {"messages": [response]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a ToolMessage result added for this? Since it's returned into graph state, will it stay in the conversation history and get replayed on the next turn as a tool call with no matching result? Would that be an issue?

@maxduu maxduu Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this works the same way as the end_execution tool, where the LLM adds the AIMessage with the tool-call inputs. The tool itself is never executed since we immediately route to the TERMINATE node, so there's no ToolMessage result.

To ensure this AIMessage is not added to chat-history, we use config_without_streaming here - that makes it so it doesn't stream any events to CAS and thus won't be added to the conversation-history. This is the same pattern used for the analyze-attachments tool which also makes an LLM-call that isn't streamed to CAS.

Finally, we ensure the AIMessage isn't part of the uipath__agent_response_messages by parsing it out here with new_conversation_messages = state.messages[initial_count:-1].

So in summary - no ToolMessage since the tool is never executed (route directly to TERMINATE node) and then the AIMessage is never streamed to CAS, and additionally not part of the output because we extract it out.


return conversational_output_node
46 changes: 33 additions & 13 deletions src/uipath_langchain/agent/react/router_conversational.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,47 @@
logger = logging.getLogger(__name__)


def create_route_agent_conversational(valid_targets: Container[str] | None = None):
def create_route_agent_conversational(
valid_targets: Container[str] | None = None,
with_generate_output_node: bool = False,
):
"""Create a routing function for conversational agents. It routes between agent and tool calls until
the agent response has no tool calls, then it routes to the USER_MESSAGE_WAIT node which does an interrupt.
the agent response has no tool calls, then it routes to either the
GENERATE_CONVERSATIONAL_OUTPUT node (when the agent declares custom output
fields) or directly to TERMINATE.

Args:
valid_targets: Allowed routing destinations
valid_targets: Allowed routing destinations.
with_generate_output_node: When True, route AGENT-without-tool-calls to the
GENERATE_CONVERSATIONAL_OUTPUT node so the structured output can
be extracted before TERMINATE. When False, route straight to
TERMINATE.
Returns:
Routing function for LangGraph conditional edges
"""

def route_agent_conversational(
state: AgentGraphState,
) -> str | Literal[AgentGraphNode.TERMINATE] | Literal[AgentGraphNode.AGENT]:
"""Route after agent
) -> (
str
| Literal[AgentGraphNode.TERMINATE]
| Literal[AgentGraphNode.AGENT]
| Literal[AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT]
):
"""Route after agent.

Routing logic:
3. If tool calls, route to specific tool nodes (return list of tool names)
4. If no tool calls, route to user message wait node

Returns:
- str: Tool node name for sequential execution
- AgentGraphNode.USER_MESSAGE_WAIT: When there are no tool calls
- If the latest AIMessage has tool calls
- If pending tools, route to the next pending tool node.
- Otherwise: route to AGENT as all tool calls completed.
- Otherwise:
- If schema declares custom output fields: route to
GENERATE_CONVERSATIONAL_OUTPUT to generate the output fields.
- Otherwise: route straight to TERMINATE.

Raises:
AgentNodeRoutingException: When encountering unexpected state (empty messages, non-AIMessage, or excessive completions)
AgentRuntimeError: ROUTING_ERROR when state has no AIMessage, or
when a routed tool name is not in `valid_targets`.
"""
last_message = find_latest_ai_message(state.messages)
if last_message is None:
Expand Down Expand Up @@ -77,6 +93,10 @@ def route_agent_conversational(

return current_tool_name
else:
return AgentGraphNode.TERMINATE
return (
AgentGraphNode.GENERATE_CONVERSATIONAL_OUTPUT
if with_generate_output_node
else AgentGraphNode.TERMINATE
)

return route_agent_conversational
Loading
Loading