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
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.14.3"
version = "0.14.4"
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
4 changes: 1 addition & 3 deletions src/uipath_langchain/agent/react/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,9 @@
and has_custom_conversational_output_fields(output_schema)
)

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

CompleteAgentGraphState = create_state_with_input(

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "CompleteAgentGraphState" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ9J9f8vNSGUy0n8DPak&open=AZ9J9f8vNSGUy0n8DPak&pullRequest=979
input_schema if input_schema is not None else BaseModel
)

Expand Down
27 changes: 22 additions & 5 deletions src/uipath_langchain/agent/react/conversational_output_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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 import SET_CONVERSATIONAL_OUTPUT_TOOL
from uipath.agent.react.conversational_prompts import (
get_generate_output_prompt,
)
Expand All @@ -35,7 +36,7 @@ def create_conversational_output_node(
model: BaseChatModel,
agent_output_schema: type[BaseModel],
):
"""Build the focused structured-output extraction node.
"""Build the conversational structured-output node.

Args:
model: The chat model to invoke for the extraction call. Reused from
Expand All @@ -61,9 +62,6 @@ def create_conversational_output_node(
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))

Expand Down Expand Up @@ -96,6 +94,25 @@ async def conversational_output_node(state: StateT):

payload_handler.check_stop_reason(response)

return {"messages": [response]}
set_output_call = next(
(
tc
for tc in (response.tool_calls or [])
if tc["name"] == SET_CONVERSATIONAL_OUTPUT_TOOL.name
),
None,
)
if set_output_call is None:
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.LLM_INVALID_RESPONSE,
title="Structured-output LLM did not return set_conversational_output.",
detail=(
"The language model was expected to call the set_conversational_output tool "
"to return the structured output for the turn, but no such call was made."
),
category=UiPathErrorCategory.SYSTEM,
)

return {"inner_state": {"conversational_output": set_output_call["args"]}}

return conversational_output_node
56 changes: 7 additions & 49 deletions src/uipath_langchain/agent/react/terminate_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
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
Expand Down Expand Up @@ -55,7 +54,6 @@ def _handle_raise_error(args: dict[str, Any]) -> NoReturn:
def _handle_end_conversational(
state: AgentGraphState,
response_schema: type[BaseModel] | None,
with_conversational_output_node: bool = False,
) -> dict[str, Any]:
"""Handle conversational agent termination by returning converted messages with optional structured output fields."""

Expand All @@ -76,56 +74,19 @@ def _handle_end_conversational(
)

initial_count = state.inner_state.initial_message_count

custom_output_fields: dict[str, Any] = {}
if with_conversational_output_node:
# with_conversational_output_node means that a node before TERMINATE
# produces an AIMessage carrying a `set_conversational_output` tool call.
# Extract the custom-field args from that call, and strip its AIMessage from message-history.
last_ai_message = state.messages[-1] if state.messages else None

if not isinstance(last_ai_message, AIMessage):
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.STATE_ERROR,
title="Expected last message to be an AIMessage for conversational agent termination.",
detail=(
"Last message in state is expected to be an AIMessage containing a `set_conversational_output` tool call."
),
category=UiPathErrorCategory.SYSTEM,
)

set_output_call = next(
(
tc
for tc in (last_ai_message.tool_calls or [])
if tc["name"] == SET_CONVERSATIONAL_OUTPUT_TOOL.name
),
None,
)
if set_output_call is None:
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.STATE_ERROR,
title="No set_conversational_output tool call found.",
detail=(
"The conversational output node was expected to produce a set_conversational_output tool call "
"in the last AIMessage, but none was found."
),
category=UiPathErrorCategory.SYSTEM,
)

custom_output_fields = dict(set_output_call["args"])
new_conversation_messages = state.messages[initial_count:-1]
else:
new_conversation_messages = state.messages[initial_count:]
# Populated by GENERATE_CONVERSATIONAL_OUTPUT when the output schema has custom
# fields; None otherwise. Missing required fields surface via schema validation below.
custom_output_fields = state.inner_state.conversational_output or {}
new_messages = state.messages[initial_count:]

converted_messages: list[UiPathConversationMessageData] = []

# For the agent-output messages, don't include tool-results. Just include agent's LLM outputs and tool-calls + inputs.
# This is primarily since evaluations don't check for tool-results; this output represents the agent's actual choices rather than tool-results.
if new_conversation_messages:
if new_messages:
converted_messages = (
UiPathChatMessagesMapper.map_langchain_messages_to_uipath_message_data_list(
messages=new_conversation_messages, include_tool_results=False
messages=new_messages, include_tool_results=False
)
)

Expand Down Expand Up @@ -155,7 +116,6 @@ def _handle_end_conversational(
def create_terminate_node(
response_schema: type[BaseModel] | None = None,
is_conversational: bool = False,
with_conversational_output_node: bool = False,
):
"""Handles Agent Graph termination for multiple sources and output or error propagation to Orchestrator.

Expand All @@ -167,9 +127,7 @@ def create_terminate_node(

def terminate_node(state: AgentGraphState):
if is_conversational:
return _handle_end_conversational(
state, response_schema, with_conversational_output_node
)
return _handle_end_conversational(state, response_schema)
else:
last_message = state.messages[-1]
if not isinstance(last_message, AIMessage):
Expand Down
1 change: 1 addition & 0 deletions src/uipath_langchain/agent/react/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class InnerAgentGraphState(BaseModel):
initial_message_count: int | None = None
tools_storage: Annotated[dict[Hashable, Any], merge_dicts] = {}
memory_injection: str = ""
conversational_output: dict[str, Any] | None = None


class InnerAgentGuardrailsGraphState(InnerAgentGraphState):
Expand Down
14 changes: 14 additions & 0 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,20 @@ async def map_ai_message_chunk_to_events(

# For every new message_id, start a new message
if message.id not in self.seen_message_ids:
# Edge-case: OpenAI Responses API (e.g. gpt-5.4) emits an initial envelope chunk
# of type `response.created` (id=`resp_...`) before any actual content;
# LangChain then streams the content under a different id (`lc_run--...`).
# This guard prevents emitting phantom startMessage/startContentPart events
# for envelope events like this. The `chunk_position != "last"` clause preserves the
# rare single-empty-chunk case (e.g. a refusal) so we still emit start + end.
if (
not message.content_blocks
and not message.tool_calls
and not (isinstance(message.content, str) and message.content)
and message.chunk_position != "last"
):
return []

self.current_message = message
self.seen_message_ids.add(message.id)
self._citation_stream_processor = CitationStreamProcessor()
Expand Down
47 changes: 39 additions & 8 deletions tests/agent/react/test_conversational_output_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,20 @@ def _make_mock_model() -> tuple[MagicMock, MagicMock, MagicMock]:

class TestCreateConversationalOutputNode:
@pytest.mark.asyncio
async def test_returns_response_with_tool_call(self):
async def test_returns_extracted_args_via_inner_state(self):
model, _non_streaming, _bound = _make_mock_model()
node = create_conversational_output_node(model, _OutputSchema)

state = _make_state([SystemMessage(content="sys"), HumanMessage(content="hi")])
result = await node(state)

assert len(result["messages"]) == 1
ai = result["messages"][0]
assert isinstance(ai, AIMessage)
assert ai.tool_calls[0]["name"] == SET_CONVERSATIONAL_OUTPUT_TOOL.name
assert ai.tool_calls[0]["args"]["handoff_target"] == "billing"
assert "messages" not in result
assert result["inner_state"] == {
"conversational_output": {
"handoff_target": "billing",
"ready_for_handoff": True,
}
}

@pytest.mark.asyncio
async def test_appends_human_instruction_for_llm_call(self):
Expand All @@ -101,8 +103,8 @@ async def test_appends_human_instruction_for_llm_call(self):
assert isinstance(invoked_messages[-1], HumanMessage)
assert "set_conversational_output" in invoked_messages[-1].content

# The instruction was NOT persisted into the returned messages.
assert len(result["messages"]) == 1
# Nothing is written to the messages channel — args flow via inner_state.
assert "messages" not in result

@pytest.mark.asyncio
async def test_binds_only_set_conversational_output_tool(self):
Expand Down Expand Up @@ -141,3 +143,32 @@ async def test_disables_streaming_on_internal_llm(self):
update_arg = model.model_copy.call_args.kwargs.get("update")
assert update_arg is not None
assert update_arg.get("disable_streaming") is True

@pytest.mark.asyncio
async def test_raises_when_llm_omits_set_conversational_output_call(self):
"""When the internal LLM returns an AIMessage without the expected
set_conversational_output tool call, the node raises a structured
AgentRuntimeError rather than propagating an empty inner_state
payload downstream."""
from uipath_langchain.agent.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
)

response = AIMessage(content="no tool call here", tool_calls=[])
bound = MagicMock()
bound.ainvoke = AsyncMock(return_value=response)
non_streaming = MagicMock()
non_streaming.bind_tools = MagicMock(return_value=bound)
model = MagicMock()
model.model_copy = MagicMock(return_value=non_streaming)

node = create_conversational_output_node(model, _OutputSchema)
state = _make_state([SystemMessage(content="sys"), HumanMessage(content="hi")])

with pytest.raises(AgentRuntimeError) as exc_info:
await node(state)

assert exc_info.value.error_info.code == AgentRuntimeError.full_code(
AgentRuntimeErrorCode.LLM_INVALID_RESPONSE
)
2 changes: 0 additions & 2 deletions tests/agent/react/test_create_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ def test_autonomous_agent_with_tools(
mock_create_terminate_node.assert_called_once_with(
None, # output schema
False, # is_conversational
False, # with_conversational_output_node
)

@_patch("create_route_agent_conversational")
Expand Down Expand Up @@ -254,7 +253,6 @@ def test_conversational_agent_with_tools(
mock_create_terminate_node.assert_called_once_with(
None, # output schema
True, # is_conversational
False, # with_conversational_output_node (no custom output schema)
)

@_patch("create_route_agent_conversational")
Expand Down
36 changes: 12 additions & 24 deletions tests/agent/react/test_terminate_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
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
Expand All @@ -25,6 +24,7 @@ class MockInnerState(BaseModel):

job_attachments: dict[str, Any] = {}
initial_message_count: int | None = None
conversational_output: dict[str, Any] | None = None


class MockAgentGraphState(BaseModel):
Expand Down Expand Up @@ -178,50 +178,38 @@ class ResponseSchema(BaseModel):
assert messages[0]["toolCalls"][0]["input"] == {"param": "value"}

def test_conversational_extracts_custom_output_fields(self):
"""When the response schema has custom fields, the terminate node
reads them from the last AIMessage's set_conversational_output tool
call and merges them with the response_messages."""
"""When inner_state.conversational_output is populated (by the
upstream GENERATE_CONVERSATIONAL_OUTPUT node), the terminate node
merges those args into the schema-shaped output."""

class ResponseSchema(BaseModel):
uipath__agent_response_messages: list[UiPathConversationMessageData]
handoff_target: str
ready_for_handoff: bool

terminate_node = create_terminate_node(
response_schema=ResponseSchema,
is_conversational=True,
with_conversational_output_node=True,
response_schema=ResponseSchema, is_conversational=True
)

agent_reply = AIMessage(content="Sure, I'll route you to billing.")
set_output_msg = AIMessage(
content="",
tool_calls=[
{
"name": SET_CONVERSATIONAL_OUTPUT_TOOL.name,
"args": {
"handoff_target": "billing",
"ready_for_handoff": True,
},
"id": "call_1",
}
],
)
state = MockAgentGraphState(
messages=[
HumanMessage(content="I have a billing issue"),
agent_reply,
set_output_msg,
],
inner_state=MockInnerState(initial_message_count=1),
inner_state=MockInnerState(
initial_message_count=1,
conversational_output={
"handoff_target": "billing",
"ready_for_handoff": True,
},
),
)

result = terminate_node(state)

assert result["handoff_target"] == "billing"
assert result["ready_for_handoff"] is True
# The conversational reply is preserved; the set_conversational_output
# AIMessage is dropped by the flow-control filter in the converter.
messages = result["uipath__agent_response_messages"]
assert len(messages) == 1
assert "Sure, I'll route you to billing." in str(
Expand Down
Loading
Loading