From 44beb4adddb194a3e58d588d2cb0eb5d449262e0 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:36:14 -0400 Subject: [PATCH 1/7] fix: prevent openai empty envelope chunk event --- src/uipath_langchain/runtime/messages.py | 14 +++ tests/runtime/test_chat_message_mapper.py | 133 ++++++++++++++++++---- 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 15481d347..077a9edcb 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -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. 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() diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index f293d4ef6..11db32f9c 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -1223,26 +1223,41 @@ async def test_map_event_returns_empty_list_for_ai_chunk_without_id(self): @pytest.mark.asyncio async def test_map_event_starts_new_message_for_new_id(self): - """Should emit start event for new message id.""" + """Should emit start event for new (content-bearing) message id. + + Envelope-only chunks (empty content, no tool calls, not the final + `chunk_position="last"` marker) are skipped — see + `TestEnvelopeOnlyChunkSkip` — so this test uses a chunk that carries + content to fire the start event. + """ mapper = UiPathChatMessagesMapper("test-runtime", None) - chunk = AIMessageChunk(content="", id="msg-123") + chunk = AIMessageChunk( + content="", + id="msg-123", + content_blocks=[{"type": "text", "text": "hi"}], + ) result = await mapper.map_event(chunk) assert result is not None - assert len(result) == 1 - event = result[0] - assert event.message_id == "msg-123" - assert event.start is not None - assert event.start.role == "assistant" - assert event.content_part is not None - assert event.content_part.start is not None + # First event is the combined start (message start + content_part start); + # subsequent events carry the text chunks. + start_event = result[0] + assert start_event.message_id == "msg-123" + assert start_event.start is not None + assert start_event.start.role == "assistant" + assert start_event.content_part is not None + assert start_event.content_part.start is not None @pytest.mark.asyncio async def test_map_event_tracks_seen_message_ids(self): - """Should track seen message ids.""" + """Should track seen message ids for content-bearing chunks.""" mapper = UiPathChatMessagesMapper("test-runtime", None) - chunk = AIMessageChunk(content="", id="msg-123") + chunk = AIMessageChunk( + content="", + id="msg-123", + content_blocks=[{"type": "text", "text": "hi"}], + ) await mapper.map_event(chunk) @@ -1250,13 +1265,22 @@ async def test_map_event_tracks_seen_message_ids(self): @pytest.mark.asyncio async def test_map_event_emits_text_chunk_for_subsequent_messages(self): - """Should emit text chunk event for subsequent messages with same id.""" + """Should emit text chunk event for subsequent messages with same id. + + The first chunk (content-bearing) fires startMessage + startContentPart + + the text chunk. The second chunk with the same id should emit only + the text chunk event, not another start. + """ mapper = UiPathChatMessagesMapper("test-runtime", None) - # First chunk starts the message - first_chunk = AIMessageChunk(content="", id="msg-123") + # First (content-bearing) chunk starts the message. + first_chunk = AIMessageChunk( + content="", + id="msg-123", + content_blocks=[{"type": "text", "text": "initial"}], + ) await mapper.map_event(first_chunk) - # Second chunk with text content + # Second chunk with text content and the same id. second_chunk = AIMessageChunk( content="", id="msg-123", @@ -1273,13 +1297,21 @@ async def test_map_event_emits_text_chunk_for_subsequent_messages(self): @pytest.mark.asyncio async def test_map_event_handles_raw_string_content(self): - """Should handle raw string content on chunk.""" + """Should handle raw string content on chunk. + + Uses a content-bearing first chunk to start the message; a subsequent + chunk with raw string content should emit only the chunk event. + """ mapper = UiPathChatMessagesMapper("test-runtime", None) - # First chunk starts the message - first_chunk = AIMessageChunk(content="", id="msg-123") + # First (content-bearing) chunk starts the message. + first_chunk = AIMessageChunk( + content="", + id="msg-123", + content_blocks=[{"type": "text", "text": "initial"}], + ) await mapper.map_event(first_chunk) - # Second chunk with string content + # Second chunk with raw string content and the same id. second_chunk = AIMessageChunk(content="raw content", id="msg-123") result = await mapper.map_event(second_chunk) @@ -2463,3 +2495,66 @@ async def test_normal_tool_emits_end_event(self): e for e in result if e.tool_call is not None and e.tool_call.end is not None ] assert len(tool_end_events) == 1 + + +class TestEnvelopeOnlyChunkSkip: + """OpenAI's Responses API emits a `response.created` frame first, carrying a + `resp_...` id and no content. LangChain then streams actual content chunks + under a different id (the run id `lc_run--...`). The mapper must skip that + envelope chunk so we don't fire a phantom `startMessage` for the `resp_...` + id and then a second `startMessage` for the run id.""" + + @pytest.mark.asyncio + async def test_envelope_chunk_emits_no_events(self): + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + envelope = AIMessageChunk(content="", id="resp_01env") + result = await mapper.map_event(envelope) + + assert result == [] + assert "resp_01env" not in mapper.seen_message_ids + + @pytest.mark.asyncio + async def test_content_chunk_after_envelope_fires_single_start(self): + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + # Envelope frame (Responses API `response.created`) — should be skipped. + envelope = AIMessageChunk(content="", id="resp_01env") + envelope_events = await mapper.map_event(envelope) + assert envelope_events == [] + + # LangChain then streams under a different id (the run id). + content_chunk = AIMessageChunk(content="Hi", id="lc_run--abc") + content_events = await mapper.map_event(content_chunk) + + # Exactly one startMessage event should be emitted, for the run id. + start_events = [e for e in (content_events or []) if e.start is not None] + assert len(start_events) == 1 + assert content_events is not None + assert start_events[0].message_id == "lc_run--abc" + + @pytest.mark.asyncio + async def test_truly_empty_last_chunk_still_starts_and_ends(self): + """When a stream produces only a single empty chunk marked + `chunk_position="last"` (e.g. a refusal with no text), the mapper must + NOT drop it — the chat UI still needs start + end events to close out + the turn.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + empty_last = AIMessageChunk(content="", id="msg-empty") + object.__setattr__(empty_last, "chunk_position", "last") + + result = await mapper.map_event(empty_last) + assert result is not None + + # Should have fired start events AND a message_end (single-chunk turn). + start_events = [e for e in result if e.start is not None] + end_events = [e for e in result if e.end is not None and e.content_part is None] + assert len(start_events) == 1 + assert len(end_events) == 1 From 05bc9e9b2991baad750f470822f4fe46c20c98db Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:38:54 -0400 Subject: [PATCH 2/7] fix: refactor to not append to messages --- src/uipath_langchain/agent/react/agent.py | 4 +- .../agent/react/conversational_output_node.py | 27 ++++++++-- .../agent/react/terminate_node.py | 52 ++----------------- src/uipath_langchain/agent/react/types.py | 1 + .../react/test_conversational_output_node.py | 51 +++++++++++++++--- tests/agent/react/test_create_agent.py | 2 - tests/agent/react/test_terminate_node.py | 36 +++++-------- 7 files changed, 84 insertions(+), 89 deletions(-) diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index f485c2c0e..6aa12b5a9 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -135,9 +135,7 @@ def create_agent( 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( input_schema if input_schema is not None else BaseModel diff --git a/src/uipath_langchain/agent/react/conversational_output_node.py b/src/uipath_langchain/agent/react/conversational_output_node.py index 54e9308e8..2660686fe 100644 --- a/src/uipath_langchain/agent/react/conversational_output_node.py +++ b/src/uipath_langchain/agent/react/conversational_output_node.py @@ -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, ) @@ -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 @@ -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)) @@ -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 diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index 68222b7aa..d08e0730a 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -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 @@ -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.""" @@ -76,47 +74,10 @@ 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_conversation_messages = state.messages[initial_count:] converted_messages: list[UiPathConversationMessageData] = [] @@ -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. @@ -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): diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 4dda11f6d..9a890e8c5 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -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): diff --git a/tests/agent/react/test_conversational_output_node.py b/tests/agent/react/test_conversational_output_node.py index 735c89453..73ab80418 100644 --- a/tests/agent/react/test_conversational_output_node.py +++ b/tests/agent/react/test_conversational_output_node.py @@ -64,18 +64,24 @@ 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): + """The extracted set_conversational_output args land on + inner_state.conversational_output_args, not on `messages` — that + keeps the synthetic tool call off LangGraph's messages-stream + sweep so it never leaks to CAS.""" 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): @@ -101,8 +107,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): @@ -141,3 +147,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 + ) diff --git a/tests/agent/react/test_create_agent.py b/tests/agent/react/test_create_agent.py index 7318dc7da..e569fc8fa 100644 --- a/tests/agent/react/test_create_agent.py +++ b/tests/agent/react/test_create_agent.py @@ -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") @@ -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") diff --git a/tests/agent/react/test_terminate_node.py b/tests/agent/react/test_terminate_node.py index c2fbfca70..697245735 100644 --- a/tests/agent/react/test_terminate_node.py +++ b/tests/agent/react/test_terminate_node.py @@ -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 @@ -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): @@ -178,9 +178,9 @@ 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] @@ -188,40 +188,28 @@ class ResponseSchema(BaseModel): 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( From 3d2b5065958db424d0f5e2718455b8d3f2097db8 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:40:10 -0400 Subject: [PATCH 3/7] chore: rename var --- src/uipath_langchain/agent/react/terminate_node.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/uipath_langchain/agent/react/terminate_node.py b/src/uipath_langchain/agent/react/terminate_node.py index d08e0730a..b58fb28c3 100644 --- a/src/uipath_langchain/agent/react/terminate_node.py +++ b/src/uipath_langchain/agent/react/terminate_node.py @@ -77,16 +77,16 @@ def _handle_end_conversational( # 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_conversation_messages = state.messages[initial_count:] + 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 ) ) From a981fc2c76871b2bc94fe654964ebc31f91163e1 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:50:38 -0400 Subject: [PATCH 4/7] fix: comment --- src/uipath_langchain/runtime/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 077a9edcb..b6dab7a3b 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -377,7 +377,7 @@ async def map_ai_message_chunk_to_events( # 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. The `chunk_position != "last"` clause preserves the + # 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 From f2a08825aff9a3f5720ce64a0bc9d27926354aef Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:05:19 -0400 Subject: [PATCH 5/7] fix: remove outdated comment --- tests/agent/react/test_conversational_output_node.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/agent/react/test_conversational_output_node.py b/tests/agent/react/test_conversational_output_node.py index 73ab80418..2a4981c3a 100644 --- a/tests/agent/react/test_conversational_output_node.py +++ b/tests/agent/react/test_conversational_output_node.py @@ -65,10 +65,6 @@ def _make_mock_model() -> tuple[MagicMock, MagicMock, MagicMock]: class TestCreateConversationalOutputNode: @pytest.mark.asyncio async def test_returns_extracted_args_via_inner_state(self): - """The extracted set_conversational_output args land on - inner_state.conversational_output_args, not on `messages` — that - keeps the synthetic tool call off LangGraph's messages-stream - sweep so it never leaks to CAS.""" model, _non_streaming, _bound = _make_mock_model() node = create_conversational_output_node(model, _OutputSchema) From f8e292de54e797fb0a99a2b91a340f6a97a0af51 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:12:38 -0400 Subject: [PATCH 6/7] chore: bump versions --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d042e85fc..19139fcc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.14.4" +version = "0.14.5" 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/uv.lock b/uv.lock index 241e92601..e313c588e 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.4" +version = "0.14.5" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From 4babd3f5e26dfa143c50ed21456c88d5ded1d48d Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:13:24 -0400 Subject: [PATCH 7/7] fix: error log --- src/uipath_langchain/agent/react/conversational_output_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uipath_langchain/agent/react/conversational_output_node.py b/src/uipath_langchain/agent/react/conversational_output_node.py index 2660686fe..761412fc3 100644 --- a/src/uipath_langchain/agent/react/conversational_output_node.py +++ b/src/uipath_langchain/agent/react/conversational_output_node.py @@ -105,7 +105,7 @@ async def conversational_output_node(state: StateT): if set_output_call is None: raise AgentRuntimeError( code=AgentRuntimeErrorCode.LLM_INVALID_RESPONSE, - title="Structured-output LLM did not return set_conversational_output.", + title="Structured-output LLM did not call 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."