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.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"
Expand Down
3 changes: 2 additions & 1 deletion src/uipath_langchain/chat/hitl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 9 additions & 9 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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,
),
),
)
Expand Down
7 changes: 4 additions & 3 deletions tests/agent/tools/test_tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
create_tool_node,
)
from uipath_langchain.chat.hitl import (
ARGS_MODIFIED_MESSAGE,
CANCELLED_MESSAGE,
CONVERSATIONAL_APPROVED_TOOL_ARGS,
)
Expand Down Expand Up @@ -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(
Expand All @@ -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"]

Expand Down Expand Up @@ -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"]

Expand Down
3 changes: 2 additions & 1 deletion tests/chat/test_hitl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand Down
80 changes: 27 additions & 53 deletions tests/runtime/test_chat_message_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -1720,26 +1720,24 @@ 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",
tool_calls=[{"id": "tc-1", "name": "confirm_tool", "args": {"x": 1}}],
)
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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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}
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading