diff --git a/pyproject.toml b/pyproject.toml index fc5ca1893..16f5ae19a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.8.26" +version = "0.8.27" 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/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 228d1b365..f70bf98c5 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -14,6 +14,7 @@ from uipath_langchain._utils.durable_interrupt import durable_interrupt CANCELLED_MESSAGE = "Cancelled by user" +ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments" CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args" REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation" @@ -55,7 +56,7 @@ def annotate_result(self, output: dict[str, Any] | Any) -> None: msg.content = json.dumps( { "meta": { - "args_modified_by_user": True, + "message": ARGS_MODIFIED_MESSAGE, "executed_args": self.approved_args, }, "result": result_value, diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 43aba1626..c145789ad 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -391,16 +391,11 @@ async def map_current_message_to_start_tool_call_events(self): self.current_message.id ) - # if tool requires confirmation, we skip start tool call - if ( - tool_call["name"] - not in self.tool_names_requiring_confirmation - ): - events.append( - self.map_tool_call_to_tool_call_start_event( - self.current_message.id, tool_call - ) + events.append( + self.map_tool_call_to_tool_call_start_event( + self.current_message.id, tool_call ) + ) if self.storage is not None: await self.storage.set_value( @@ -499,6 +494,10 @@ async def get_message_id_for_tool_call( def map_tool_call_to_tool_call_start_event( self, message_id: str, tool_call: ToolCall ) -> UiPathConversationMessageEvent: + metadata = None + if tool_call["name"] in self.tool_names_requiring_confirmation: + metadata = {"requiresConfirmation": True} + return UiPathConversationMessageEvent( message_id=message_id, tool_call=UiPathConversationToolCallEvent( @@ -507,6 +506,7 @@ def map_tool_call_to_tool_call_start_event( tool_name=tool_call["name"], timestamp=self.get_timestamp(), input=tool_call["args"], + metadata=metadata, ), ), ) diff --git a/tests/agent/tools/test_tool_node.py b/tests/agent/tools/test_tool_node.py index 870cedf18..8551c5b07 100644 --- a/tests/agent/tools/test_tool_node.py +++ b/tests/agent/tools/test_tool_node.py @@ -22,6 +22,7 @@ create_tool_node, ) from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE, CONVERSATIONAL_APPROVED_TOOL_ARGS, ) @@ -555,7 +556,7 @@ def test_approved_same_args_no_meta( assert result is not None assert isinstance(result, dict) msg = result["messages"][0] - assert "args_modified_by_user" not in msg.content + assert ARGS_MODIFIED_MESSAGE not in msg.content assert "Mock result:" in msg.content @patch( @@ -576,7 +577,7 @@ def test_approved_modified_args_injects_meta( assert isinstance(msg.content, str) wrapped = json.loads(msg.content) - assert wrapped["meta"]["args_modified_by_user"] is True + assert wrapped["meta"]["message"] == ARGS_MODIFIED_MESSAGE assert wrapped["meta"]["executed_args"] == {"input_text": "edited"} assert "Mock result: edited" in wrapped["result"] @@ -612,7 +613,7 @@ async def test_async_approved_modified_args( assert isinstance(msg.content, str) wrapped = json.loads(msg.content) - assert wrapped["meta"]["args_modified_by_user"] is True + assert wrapped["meta"]["message"] == ARGS_MODIFIED_MESSAGE assert wrapped["meta"]["executed_args"] == {"input_text": "async edited"} assert "Async mock result: async edited" in wrapped["result"] diff --git a/tests/chat/test_hitl.py b/tests/chat/test_hitl.py index 5ef910324..a07f423c8 100644 --- a/tests/chat/test_hitl.py +++ b/tests/chat/test_hitl.py @@ -8,6 +8,7 @@ from langchain_core.tools import BaseTool from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE, CONVERSATIONAL_APPROVED_TOOL_ARGS, ConfirmationResult, @@ -138,7 +139,7 @@ def test_annotate_wraps_content_when_modified(self): assert isinstance(msg.content, str) wrapped = json.loads(msg.content) - assert wrapped["meta"]["args_modified_by_user"] is True + assert wrapped["meta"]["message"] == ARGS_MODIFIED_MESSAGE assert wrapped["meta"]["executed_args"] == {"query": "edited"} assert wrapped["result"] == "result" diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 35db6a912..295aee412 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -1720,18 +1720,17 @@ def test_ai_message_with_media_citation(self): assert source.page_number == "3" -class TestConfirmationToolDeferral: - """Tests for deferring startToolCall events for confirmation tools.""" +class TestConfirmationToolMetadata: + """Confirmation tools emit startToolCall with requiresConfirmation metadata.""" @pytest.mark.asyncio - async def test_start_tool_call_skipped_for_confirmation_tool(self): - """AIMessageChunk with confirmation tool should NOT emit startToolCall.""" + async def test_confirmation_tool_has_requires_confirmation_metadata(self): + """startToolCall for confirmation tools includes requiresConfirmation in metadata.""" storage = create_mock_storage() storage.get_value.return_value = {} mapper = UiPathChatMessagesMapper("test-runtime", storage) mapper.tool_names_requiring_confirmation = {"confirm_tool"} - # First chunk starts the message with a confirmation tool call first_chunk = AIMessageChunk( content="", id="msg-1", @@ -1739,7 +1738,6 @@ async def test_start_tool_call_skipped_for_confirmation_tool(self): ) await mapper.map_event(first_chunk) - # Last chunk triggers tool call start events last_chunk = AIMessageChunk(content="", id="msg-1") object.__setattr__(last_chunk, "chunk_position", "last") result = await mapper.map_event(last_chunk) @@ -1750,11 +1748,16 @@ async def test_start_tool_call_skipped_for_confirmation_tool(self): for e in result if e.tool_call is not None and e.tool_call.start is not None ] - assert len(tool_start_events) == 0 + assert len(tool_start_events) >= 1 + event = tool_start_events[0] + assert event.tool_call is not None + assert event.tool_call.start is not None + assert event.tool_call.start.tool_name == "confirm_tool" + assert event.tool_call.start.metadata == {"requiresConfirmation": True} @pytest.mark.asyncio - async def test_start_tool_call_emitted_for_non_confirmation_tool(self): - """Normal tools still emit startToolCall even when confirmation set is populated.""" + async def test_normal_tool_has_no_confirmation_metadata(self): + """startToolCall for normal tools has no metadata.""" storage = create_mock_storage() storage.get_value.return_value = {} mapper = UiPathChatMessagesMapper("test-runtime", storage) @@ -1778,44 +1781,14 @@ async def test_start_tool_call_emitted_for_non_confirmation_tool(self): if e.tool_call is not None and e.tool_call.start is not None ] assert len(tool_start_events) >= 1 - assert tool_start_events[0].tool_call is not None - assert tool_start_events[0].tool_call.start is not None - assert tool_start_events[0].tool_call.start.tool_name == "normal_tool" - - @pytest.mark.asyncio - async def test_confirmation_tool_message_emits_only_end(self): - """ToolMessage for a confirmation tool should only emit endToolCall + messageEnd. - - startToolCall is now emitted by the bridge on HITL approval, not here. - """ - storage = create_mock_storage() - storage.get_value.return_value = {"tc-3": "msg-3"} - mapper = UiPathChatMessagesMapper("test-runtime", storage) - mapper.tool_names_requiring_confirmation = {"confirm_tool"} - - tool_msg = ToolMessage( - content='{"result": "ok"}', - tool_call_id="tc-3", - name="confirm_tool", - ) - - result = await mapper.map_event(tool_msg) - - assert result is not None - # Should have: endToolCall, messageEnd (no startToolCall) - assert len(result) == 2 - - # First event: endToolCall - end_event = result[0] - assert end_event.tool_call is not None - assert end_event.tool_call.end is not None - - # Second event: messageEnd - assert result[1].end is not None + event = tool_start_events[0] + assert event.tool_call is not None + assert event.tool_call.start is not None + assert event.tool_call.start.metadata is None @pytest.mark.asyncio - async def test_mixed_tools_only_confirmation_deferred(self): - """Mixed tools in one AIMessage: only confirmation tool's startToolCall is deferred.""" + async def test_mixed_tools_only_confirmation_has_metadata(self): + """In mixed tool calls, only confirmation tools get the metadata flag.""" storage = create_mock_storage() storage.get_value.return_value = {} mapper = UiPathChatMessagesMapper("test-runtime", storage) @@ -1836,11 +1809,12 @@ async def test_mixed_tools_only_confirmation_deferred(self): result = await mapper.map_event(last_chunk) assert result is not None - tool_start_names = [ - e.tool_call.start.tool_name - for e in result - if e.tool_call is not None and e.tool_call.start is not None - ] - # normal_tool should have startToolCall, confirm_tool should NOT - assert "normal_tool" in tool_start_names - assert "confirm_tool" not in tool_start_names + tool_starts = {} + for e in result: + tc = e.tool_call + if tc is not None and tc.start is not None: + tool_starts[tc.start.tool_name] = tc.start + assert "normal_tool" in tool_starts + assert "confirm_tool" in tool_starts + assert tool_starts["normal_tool"].metadata is None + assert tool_starts["confirm_tool"].metadata == {"requiresConfirmation": True} diff --git a/uv.lock b/uv.lock index 0e6d82c19..d4be2e6a6 100644 --- a/uv.lock +++ b/uv.lock @@ -3333,7 +3333,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.8.26" +version = "0.8.27" source = { editable = "." } dependencies = [ { name = "httpx" },