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
30 changes: 28 additions & 2 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 @@ -126,6 +132,11 @@ def create_agent(

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)
)

CompleteAgentGraphState = create_state_with_input(
input_schema if input_schema is not None else BaseModel
)
Expand All @@ -150,6 +161,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 +203,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"
86 changes: 86 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,86 @@
"""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.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 ..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 Exception as e:
raise_for_provider_http_error(e)
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.",
),
Comment on lines +77 to +80
category=UiPathErrorCategory.SYSTEM,
)

return {"messages": [response]}

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
66 changes: 58 additions & 8 deletions src/uipath_langchain/agent/react/terminate_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@

from langchain_core.messages import AIMessage
from pydantic import BaseModel, ValidationError
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
from uipath.agent.react import (
END_EXECUTION_TOOL,
RAISE_ERROR_TOOL,
SET_CONVERSATIONAL_OUTPUT_TOOL,
)
from uipath.core.chat import UiPathConversationMessageData
from uipath.runtime.errors import UiPathErrorCategory

from ...runtime.messages import UiPathChatMessagesMapper
from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from .constants import UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD
from .types import AgentGraphState


Expand Down Expand Up @@ -50,7 +55,15 @@ 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."""
"""Handle conversational agent termination by returning converted messages.

When the agent's output schema declares custom fields beyond
`uipath__agent_response_messages`, the GENERATE_CONVERSATIONAL_OUTPUT node
runs immediately before TERMINATE and produces an AIMessage carrying a
`set_conversational_output` tool call. The custom field values are
extracted from that tool call's args and merged with the converted
message history for the final output.
"""
if state.inner_state.initial_message_count is None:
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.STATE_ERROR,
Expand All @@ -67,8 +80,34 @@ def _handle_end_conversational(
category=UiPathErrorCategory.SYSTEM,
)

# Extract structured output fields from the last AIMessage's
# `set_conversational_output` tool call, when present.
last_ai_message = state.messages[-1] if state.messages else None
set_output_call = (
next(
(
tc
for tc in (last_ai_message.tool_calls or [])
if tc["name"] == SET_CONVERSATIONAL_OUTPUT_TOOL.name
),
None,
)
if isinstance(last_ai_message, AIMessage)
else None
)
custom_output_fields: dict[str, Any] = (
dict(set_output_call["args"]) if set_output_call is not None else {}
)
Comment on lines +98 to +100

initial_count = state.inner_state.initial_message_count
new_messages = state.messages[initial_count:]
# Drop the GENERATE_CONVERSATIONAL_OUTPUT AIMessage from the converted
# response payload — it carries no user-visible content, only the
# framework-internal set_conversational_output tool call.
new_messages = (
state.messages[initial_count:-1]

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.

If the extraction call comes back with no set_conversational_output tool call, will the empty extraction message leak into uipath__agent_response_messages as a blank assistant turn?

if set_output_call is not None
else state.messages[initial_count:]
)

converted_messages: list[UiPathConversationMessageData] = []

Expand All @@ -81,15 +120,26 @@ def _handle_end_conversational(
)
)

output = {
"uipath__agent_response_messages": [
output: dict[str, Any] = {
**custom_output_fields,
UIPATH_CONVERSATIONAL_AGENT_RESPONSE_MESSAGES_FIELD: [
msg.model_dump(by_alias=True) for msg in converted_messages
]
],
}
validated = response_schema.model_validate(output)
try:
validated = response_schema.model_validate(output)
except ValidationError as e:
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.OUTPUT_VALIDATION_ERROR,
title="Conversational agent output did not match the expected schema",
detail=(
"The conversational agent's output does not satisfy the configured output schema. "
f"Verify the output, and adjust the schema or the prompt. Details:\n{e}"
),
category=UiPathErrorCategory.USER,
) from e

# Dump with exclude_none to prevent UiPathConversation... fields with None values from being outputted (e.g. UiPathConversationContentPartData.isTranscript).
# May need to revisit if other output fields are added for conversational agents, where we want nulls outputted.
return validated.model_dump(by_alias=True, exclude_none=True)


Expand Down
21 changes: 21 additions & 0 deletions src/uipath_langchain/agent/react/tools/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
from uipath.agent.react import (
END_EXECUTION_TOOL,
RAISE_ERROR_TOOL,
SET_CONVERSATIONAL_OUTPUT_TOOL,
)

from ..utils import build_conversational_output_args_schema


def create_end_execution_tool(
agent_output_schema: type[BaseModel] | None = None,
Expand Down Expand Up @@ -41,6 +44,24 @@
)


def create_set_conversational_output_tool(
agent_output_schema: type[BaseModel],
) -> StructuredTool:
"""Called by conversational-agents at the end of the loop when custom-output
fields are declared. Never executed — args are extracted and intercepted during termination."""
input_schema = build_conversational_output_args_schema(agent_output_schema)

async def set_conversational_output_fn(**kwargs: Any) -> dict[str, Any]:

Check warning on line 54 in src/uipath_langchain/agent/react/tools/tools.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use asynchronous features in this function or remove the `async` keyword.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ8haDtEyDUB3x3r3_xI&open=AZ8haDtEyDUB3x3r3_xI&pullRequest=965
return kwargs

return StructuredTool(
name=SET_CONVERSATIONAL_OUTPUT_TOOL.name,
description=SET_CONVERSATIONAL_OUTPUT_TOOL.description,
args_schema=input_schema,
coroutine=set_conversational_output_fn,
)


def create_flow_control_tools(
agent_output_schema: type[BaseModel] | None = None,
) -> list[BaseTool]:
Expand Down
Loading
Loading