From a9ecf99d38a374875f23140f67ca357a79fdc646 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:51:16 -0500 Subject: [PATCH 1/4] use hitl for low coded cas agents --- .../agent/tools/tool_factory.py | 13 ++ src/uipath_langchain/agent/tools/tool_node.py | 33 +++- src/uipath_langchain/chat/hitl.py | 62 ++++++- tests/agent/tools/test_tool_node.py | 115 +++++++++++++ tests/chat/test_hitl.py | 156 ++++++++++++++++++ 5 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 tests/chat/test_hitl.py diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 8a87fec87..76146f72d 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -49,6 +49,19 @@ async def create_tools_from_resources( ) tool = await _build_tool_for_resource(resource, llm) if tool is not None: + # propagate requireConversationalConfirmation to tool metadata (conversational agents only) + if agent.is_conversational: + tool_list = tool if isinstance(tool, list) else [tool] + props = getattr(resource, "properties", None) + if props and getattr( + props, "require_conversational_confirmation", False + ): + # some resources (like mcp) can return a list of tools, so normalize to a list + for t in tool_list: + if t.metadata is None: + t.metadata = {} + t.metadata["require_conversational_confirmation"] = True + if isinstance(tool, list): tools.extend(tool) else: diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 6d6b9fc5b..68a0db216 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -21,6 +21,11 @@ extract_current_tool_call_index, find_latest_ai_message, ) +from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, + check_tool_confirmation, + inject_confirmation_meta, +) # the type safety can be improved with generics ToolWrapperReturnType = dict[str, Any] | Command[Any] | None @@ -79,6 +84,11 @@ def _func(self, state: AgentGraphState) -> OutputType: if call is None: return None + confirmation = check_tool_confirmation(call, self.tool) + if confirmation is not None: + if confirmation.cancelled: + return self._process_result(call, confirmation.cancelled) + try: if self.wrapper: inputs = self._prepare_wrapper_inputs( @@ -87,7 +97,14 @@ def _func(self, state: AgentGraphState) -> OutputType: result = self.wrapper(*inputs) else: result = self.tool.invoke(call) - return self._process_result(call, result) + output = self._process_result(call, result) + if ( + confirmation is not None + and confirmation.args_modified + and isinstance(output, dict) + ): + inject_confirmation_meta(output["messages"][0], ARGS_MODIFIED_MESSAGE) + return output except Exception as e: if self.handle_tool_errors: return self._process_error_result(call, e) @@ -98,6 +115,11 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: if call is None: return None + confirmation = check_tool_confirmation(call, self.tool) + if confirmation is not None: + if confirmation.cancelled: + return self._process_result(call, confirmation.cancelled) + try: if self.awrapper: inputs = self._prepare_wrapper_inputs( @@ -106,7 +128,14 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: result = await self.awrapper(*inputs) else: result = await self.tool.ainvoke(call) - return self._process_result(call, result) + output = self._process_result(call, result) + if ( + confirmation is not None + and confirmation.args_modified + and isinstance(output, dict) + ): + inject_confirmation_meta(output["messages"][0], ARGS_MODIFIED_MESSAGE) + return output except Exception as e: if self.handle_tool_errors: return self._process_error_result(call, e) diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 625fc9a63..18bb12d6d 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -1,8 +1,9 @@ import functools import inspect from inspect import Parameter -from typing import Annotated, Any, Callable +from typing import Annotated, Any, Callable, NamedTuple +from langchain_core.messages.tool import ToolCall, ToolMessage from langchain_core.tools import BaseTool, InjectedToolCallId from langchain_core.tools import tool as langchain_tool from langgraph.types import interrupt @@ -10,7 +11,15 @@ UiPathConversationToolCallConfirmationValue, ) -_CANCELLED_MESSAGE = "Cancelled by user" +CANCELLED_MESSAGE = "Cancelled by user" +ARGS_MODIFIED_MESSAGE = "Tool arguments were modified by the user" + + +class ConfirmationResult(NamedTuple): + """Result of a tool confirmation check.""" + + cancelled: ToolMessage | None # ToolMessage if cancelled, None if approved + args_modified: bool def _patch_span_input(approved_args: dict[str, Any]) -> None: @@ -53,7 +62,7 @@ def _patch_span_input(approved_args: dict[str, Any]) -> None: pass -def _request_approval( +def request_approval( tool_args: dict[str, Any], tool: BaseTool, ) -> dict[str, Any] | None: @@ -89,7 +98,48 @@ def _request_approval( if not confirmation.get("approved", True): return None - return confirmation.get("input") or tool_args + return ( + confirmation.get("input") + if confirmation.get("input") is not None + else tool_args + ) + + +def inject_confirmation_meta(message: ToolMessage, meta: str) -> None: + """Inject a meta note into a ToolMessage content.""" + # message.content = f'{{"meta": "{meta}", "result": {message.content}}}' + message.content = f'{{"result": {message.content}}}' + + +def check_tool_confirmation( + call: ToolCall, tool: BaseTool +) -> ConfirmationResult | None: + """Check if a tool requires confirmation and request approval if so. + + Returns None if no confirmation is needed. + Returns ConfirmationResult with approved_args=None if cancelled. + Returns ConfirmationResult with approved_args and args_modified flag if approved. + """ + if not (tool.metadata and tool.metadata.get("require_conversational_confirmation")): + return None + + original_args = call["args"] + approved_args = request_approval( + {**original_args, "tool_call_id": call["id"]}, tool + ) + if approved_args is None: + return ConfirmationResult( + cancelled=ToolMessage( + content=CANCELLED_MESSAGE, + name=call["name"], + tool_call_id=call["id"], + ), + args_modified=False, + ) + call["args"] = approved_args + return ConfirmationResult( + cancelled=None, args_modified=approved_args != original_args + ) def requires_approval( @@ -107,9 +157,9 @@ def decorator(fn: Callable[..., Any]) -> BaseTool: # wrap the tool/function @functools.wraps(fn) def wrapper(**tool_args: Any) -> Any: - approved_args = _request_approval(tool_args, _created_tool[0]) + approved_args = request_approval(tool_args, _created_tool[0]) if approved_args is None: - return _CANCELLED_MESSAGE + return {"meta": CANCELLED_MESSAGE} _patch_span_input(approved_args) return fn(**approved_args) diff --git a/tests/agent/tools/test_tool_node.py b/tests/agent/tools/test_tool_node.py index af3da38cb..da2a72600 100644 --- a/tests/agent/tools/test_tool_node.py +++ b/tests/agent/tools/test_tool_node.py @@ -1,6 +1,7 @@ """Tests for tool_node.py module.""" from typing import Any, Dict +from unittest.mock import patch import pytest from langchain_core.messages import AIMessage, HumanMessage @@ -18,6 +19,7 @@ UiPathToolNode, create_tool_node, ) +from uipath_langchain.chat.hitl import ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE class MockTool(BaseTool): @@ -482,3 +484,116 @@ def test_create_tool_node_with_handle_errors_true(self): node = result[tool_name] assert isinstance(node, UiPathToolNode) assert node.handle_tool_errors is True + + +class TestToolNodeConfirmation: + """Tests for confirmation flow in UiPathToolNode._func / _afunc.""" + + @pytest.fixture + def confirmation_tool(self): + """Tool with require_conversational_confirmation metadata.""" + return MockTool(metadata={"require_conversational_confirmation": True}) + + @pytest.fixture + def confirmation_state(self): + tool_call = { + "name": "mock_tool", + "args": {"input_text": "test input"}, + "id": "test_call_id", + } + ai_message = AIMessage(content="Using tool", tool_calls=[tool_call]) + return MockState(messages=[ai_message]) + + def test_no_confirmation_without_metadata(self): + """Tool without metadata executes normally, no interrupt.""" + tool = MockTool() # no metadata + node = UiPathToolNode(tool) + tool_call = { + "name": "mock_tool", + "args": {"input_text": "hello"}, + "id": "call_1", + } + state = MockState(messages=[AIMessage(content="go", tool_calls=[tool_call])]) + + result = node._func(state) + + assert result is not None + assert "Mock result: hello" in result["messages"][0].content + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + def test_cancelled_returns_cancelled_message( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Rejected confirmation returns CANCELLED_MESSAGE.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert isinstance(msg, ToolMessage) + assert msg.content == CANCELLED_MESSAGE + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "test input"}, + ) + def test_approved_same_args_no_meta( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Approved with same args → normal execution, no meta injected.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert ARGS_MODIFIED_MESSAGE not in msg.content + assert "Mock result:" in msg.content + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "edited"}, + ) + def test_approved_modified_args_injects_meta( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Approved with edited args → tool runs with new args, meta injected.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert ARGS_MODIFIED_MESSAGE in msg.content + assert "Mock result: edited" in msg.content + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + async def test_async_cancelled( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Async path: rejected confirmation returns CANCELLED_MESSAGE.""" + node = UiPathToolNode(confirmation_tool) + + result = await node._afunc(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert msg.content == CANCELLED_MESSAGE + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "async edited"}, + ) + async def test_async_approved_modified_args( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Async path: approved with edited args → meta injected.""" + node = UiPathToolNode(confirmation_tool) + + result = await node._afunc(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert ARGS_MODIFIED_MESSAGE in msg.content + assert "Async mock result: async edited" in msg.content diff --git a/tests/chat/test_hitl.py b/tests/chat/test_hitl.py new file mode 100644 index 000000000..882f5c533 --- /dev/null +++ b/tests/chat/test_hitl.py @@ -0,0 +1,156 @@ +"""Tests for hitl.py module.""" + +from unittest.mock import patch + +from langchain_core.messages.tool import ToolCall, ToolMessage +from langchain_core.tools import BaseTool + +from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, + CANCELLED_MESSAGE, + ConfirmationResult, + check_tool_confirmation, + inject_confirmation_meta, + request_approval, +) + + +class MockTool(BaseTool): + name: str = "mock_tool" + description: str = "A mock tool" + + def _run(self) -> str: + return "" + + +def _make_call(args: dict | None = None) -> ToolCall: + return ToolCall(name="mock_tool", args=args or {"query": "test"}, id="call_1") + + +class TestCheckToolConfirmation: + """Tests for check_tool_confirmation.""" + + def test_returns_none_when_no_metadata(self): + """No metadata → no confirmation needed.""" + tool = MockTool() + call = _make_call() + assert check_tool_confirmation(call, tool) is None + + def test_returns_none_when_flag_not_set(self): + """Metadata exists but flag is missing → no confirmation needed.""" + tool = MockTool(metadata={"other_key": True}) + call = _make_call() + assert check_tool_confirmation(call, tool) is None + + def test_returns_none_when_flag_false(self): + """Flag explicitly False → no confirmation needed.""" + tool = MockTool(metadata={"require_conversational_confirmation": False}) + call = _make_call() + assert check_tool_confirmation(call, tool) is None + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + def test_cancelled_returns_tool_message(self, mock_approval): + """User rejects → ConfirmationResult with cancelled ToolMessage.""" + tool = MockTool(metadata={"require_conversational_confirmation": True}) + call = _make_call() + + result = check_tool_confirmation(call, tool) + + assert result is not None + assert isinstance(result, ConfirmationResult) + assert result.cancelled is not None + assert isinstance(result.cancelled, ToolMessage) + assert result.cancelled.content == CANCELLED_MESSAGE + assert result.cancelled.name == "mock_tool" + assert result.cancelled.tool_call_id == "call_1" + assert result.args_modified is False + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"query": "test"}, + ) + def test_approved_same_args(self, mock_approval): + """User approves without editing → cancelled=None, args_modified=False.""" + tool = MockTool(metadata={"require_conversational_confirmation": True}) + call = _make_call({"query": "test"}) + + result = check_tool_confirmation(call, tool) + + assert result is not None + assert result.cancelled is None + assert result.args_modified is False + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"query": "edited"}, + ) + def test_approved_modified_args(self, mock_approval): + """User edits args → cancelled=None, args_modified=True, call updated.""" + tool = MockTool(metadata={"require_conversational_confirmation": True}) + call = _make_call({"query": "original"}) + + result = check_tool_confirmation(call, tool) + + assert result is not None + assert result.cancelled is None + assert result.args_modified is True + assert call["args"] == {"query": "edited"} + + +class TestInjectConfirmationMeta: + """Tests for inject_confirmation_meta.""" + + def test_injects_meta_into_content(self): + msg = ToolMessage(content="some result", name="t", tool_call_id="c1") + inject_confirmation_meta(msg, ARGS_MODIFIED_MESSAGE) + assert ARGS_MODIFIED_MESSAGE in msg.content + assert "some result" in msg.content + + def test_wraps_content_as_json(self): + msg = ToolMessage(content="hello", name="t", tool_call_id="c1") + inject_confirmation_meta(msg, "test meta") + assert msg.content == '{"meta": "test meta", "result": hello}' + + +class TestRequestApprovalTruthiness: + """Tests for the truthiness fix in request_approval.""" + + @patch("uipath_langchain.chat.hitl.interrupt") + def test_empty_dict_input_preserved(self, mock_interrupt): + """Empty dict from user edits should not be replaced by original args.""" + mock_interrupt.return_value = {"value": {"approved": True, "input": {}}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == {} + + @patch("uipath_langchain.chat.hitl.interrupt") + def test_empty_list_input_preserved(self, mock_interrupt): + """Empty list from user edits should not be replaced by original args.""" + mock_interrupt.return_value = {"value": {"approved": True, "input": []}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == [] + + @patch("uipath_langchain.chat.hitl.interrupt") + def test_none_input_falls_back_to_original(self, mock_interrupt): + """None input should fall back to original tool_args.""" + mock_interrupt.return_value = {"value": {"approved": True, "input": None}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == {"query": "test"} + + @patch("uipath_langchain.chat.hitl.interrupt") + def test_missing_input_falls_back_to_original(self, mock_interrupt): + """Missing input key should fall back to original tool_args.""" + mock_interrupt.return_value = {"value": {"approved": True}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result == {"query": "test"} + + @patch("uipath_langchain.chat.hitl.interrupt") + def test_rejected_returns_none(self, mock_interrupt): + """Rejected approval returns None.""" + mock_interrupt.return_value = {"value": {"approved": False}} + tool = MockTool() + result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) + assert result is None From 63a999fe119c36296c99da1d6acef857b03f17d1 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:58:19 -0500 Subject: [PATCH 2/4] emit deferred startToolCall --- pyproject.toml | 2 +- .../agent/tools/tool_factory.py | 8 +- src/uipath_langchain/agent/tools/tool_node.py | 49 ++++--- src/uipath_langchain/chat/hitl.py | 10 +- src/uipath_langchain/runtime/messages.py | 28 +++- src/uipath_langchain/runtime/runtime.py | 16 +++ tests/agent/tools/test_tool_node.py | 58 +++++++- tests/chat/test_hitl.py | 17 --- tests/runtime/test_chat_message_mapper.py | 129 ++++++++++++++++++ uv.lock | 8 +- 10 files changed, 266 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c8d6b1c7..c9aa08e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.7.7" +version = "0.7.8" 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/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 76146f72d..4a771c0d2 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -16,6 +16,8 @@ LowCodeAgentDefinition, ) +from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION + from .context_tool import create_context_tool from .escalation_tool import create_escalation_tool from .extraction_tool import create_ixp_extraction_tool @@ -53,14 +55,12 @@ async def create_tools_from_resources( if agent.is_conversational: tool_list = tool if isinstance(tool, list) else [tool] props = getattr(resource, "properties", None) - if props and getattr( - props, "require_conversational_confirmation", False - ): + if props and getattr(props, REQUIRE_CONVERSATIONAL_CONFIRMATION, False): # some resources (like mcp) can return a list of tools, so normalize to a list for t in tool_list: if t.metadata is None: t.metadata = {} - t.metadata["require_conversational_confirmation"] = True + t.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True if isinstance(tool, list): tools.extend(tool) diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 68a0db216..ce4658005 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -23,8 +23,8 @@ ) from uipath_langchain.chat.hitl import ( ARGS_MODIFIED_MESSAGE, + CONVERSATIONAL_APPROVED_TOOL_ARGS, check_tool_confirmation, - inject_confirmation_meta, ) # the type safety can be improved with generics @@ -85,9 +85,11 @@ def _func(self, state: AgentGraphState) -> OutputType: return None confirmation = check_tool_confirmation(call, self.tool) - if confirmation is not None: - if confirmation.cancelled: - return self._process_result(call, confirmation.cancelled) + if confirmation is not None and confirmation.cancelled: + confirmation.cancelled.response_metadata[ + CONVERSATIONAL_APPROVED_TOOL_ARGS + ] = call["args"] + return self._process_result(call, confirmation.cancelled) try: if self.wrapper: @@ -98,12 +100,11 @@ def _func(self, state: AgentGraphState) -> OutputType: else: result = self.tool.invoke(call) output = self._process_result(call, result) - if ( - confirmation is not None - and confirmation.args_modified - and isinstance(output, dict) - ): - inject_confirmation_meta(output["messages"][0], ARGS_MODIFIED_MESSAGE) + msg = self._get_tool_message(output) if confirmation is not None else None + if msg is not None: + msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = call["args"] + if confirmation.args_modified: + msg.content = f'{{"meta": "{ARGS_MODIFIED_MESSAGE}", "result": {msg.content}}}' return output except Exception as e: if self.handle_tool_errors: @@ -116,9 +117,11 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: return None confirmation = check_tool_confirmation(call, self.tool) - if confirmation is not None: - if confirmation.cancelled: - return self._process_result(call, confirmation.cancelled) + if confirmation is not None and confirmation.cancelled: + confirmation.cancelled.response_metadata[ + CONVERSATIONAL_APPROVED_TOOL_ARGS + ] = call["args"] + return self._process_result(call, confirmation.cancelled) try: if self.awrapper: @@ -129,12 +132,11 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: else: result = await self.tool.ainvoke(call) output = self._process_result(call, result) - if ( - confirmation is not None - and confirmation.args_modified - and isinstance(output, dict) - ): - inject_confirmation_meta(output["messages"][0], ARGS_MODIFIED_MESSAGE) + msg = self._get_tool_message(output) if confirmation is not None else None + if msg is not None: + msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = call["args"] + if confirmation.args_modified: + msg.content = f'{{"meta": "{ARGS_MODIFIED_MESSAGE}", "result": {msg.content}}}' return output except Exception as e: if self.handle_tool_errors: @@ -191,6 +193,15 @@ def _process_result( ) return {"messages": [message]} + @staticmethod + def _get_tool_message(output: OutputType) -> ToolMessage | None: + """Extract the ToolMessage from a processed output, if present.""" + if isinstance(output, dict): + messages = output.get("messages") + if messages: + return messages[0] + return None + @staticmethod def _filter_result(command: Command[Any]) -> None: """Strip NO_CONTENT markers from ToolMessages embedded in a Command.""" diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 18bb12d6d..a07bf30f8 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -13,6 +13,8 @@ CANCELLED_MESSAGE = "Cancelled by user" ARGS_MODIFIED_MESSAGE = "Tool arguments were modified by the user" +CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args" +REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation" class ConfirmationResult(NamedTuple): @@ -105,12 +107,6 @@ def request_approval( ) -def inject_confirmation_meta(message: ToolMessage, meta: str) -> None: - """Inject a meta note into a ToolMessage content.""" - # message.content = f'{{"meta": "{meta}", "result": {message.content}}}' - message.content = f'{{"result": {message.content}}}' - - def check_tool_confirmation( call: ToolCall, tool: BaseTool ) -> ConfirmationResult | None: @@ -120,7 +116,7 @@ def check_tool_confirmation( Returns ConfirmationResult with approved_args=None if cancelled. Returns ConfirmationResult with approved_args and args_modified flag if approved. """ - if not (tool.metadata and tool.metadata.get("require_conversational_confirmation")): + if not (tool.metadata and tool.metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION)): return None original_args = call["args"] diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 53712e912..f7256f5f4 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -39,6 +39,8 @@ ) from uipath.runtime import UiPathRuntimeStorageProtocol +from uipath_langchain.chat.hitl import CONVERSATIONAL_APPROVED_TOOL_ARGS + from ._citations import CitationStreamProcessor, extract_citations_from_text logger = logging.getLogger(__name__) @@ -58,6 +60,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None """Initialize the mapper with empty state.""" self.runtime_id = runtime_id self.storage = storage + self.confirmation_tool_names: set[str] = set() self.current_message: AIMessageChunk self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() @@ -389,11 +392,12 @@ async def map_current_message_to_start_tool_call_events(self): tool_call_id_to_message_id_map[tool_call_id] = ( self.current_message.id ) - events.append( - self.map_tool_call_to_tool_call_start_event( - self.current_message.id, tool_call + if tool_call["name"] not in self.confirmation_tool_names: + 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( @@ -426,7 +430,19 @@ async def map_tool_message_to_events( # Keep as string if not valid JSON pass - events = [ + events: list[UiPathConversationMessageEvent] = [] + + # Emit deferred startToolCall for confirmation tools (skipped in Pass 1) + approved_args = message.response_metadata.get(CONVERSATIONAL_APPROVED_TOOL_ARGS) + if approved_args is not None: + tool_call = ToolCall( + name=message.name or "", args=approved_args, id=message.tool_call_id + ) + events.append( + self.map_tool_call_to_tool_call_start_event(message_id, tool_call) + ) + + events.append( UiPathConversationMessageEvent( message_id=message_id, tool_call=UiPathConversationToolCallEvent( @@ -438,7 +454,7 @@ async def map_tool_message_to_events( ), ), ) - ] + ) if is_last_tool_call: events.append(self.map_to_message_end_event(message_id)) diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 228a5cdb9..dbe07f0c1 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -29,6 +29,7 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema +from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError from uipath_langchain.runtime.messages import UiPathChatMessagesMapper from uipath_langchain.runtime.schema import get_entrypoints_schema, get_graph_schema @@ -64,6 +65,7 @@ def __init__( self.entrypoint: str | None = entrypoint self.callbacks: list[BaseCallbackHandler] = callbacks or [] self.chat = UiPathChatMessagesMapper(self.runtime_id, storage) + self.chat.confirmation_tool_names = self._detect_confirmation_tools() self._middleware_node_names: set[str] = self._detect_middleware_nodes() async def execute( @@ -486,6 +488,20 @@ def _detect_middleware_nodes(self) -> set[str]: return middleware_nodes + def _detect_confirmation_tools(self) -> set[str]: + confirmation_tools: set[str] = set() + for node_name, node_spec in self.graph.nodes.items(): + bound = getattr(node_spec, "bound", None) + if bound is None: + continue + tool = getattr(bound, "tool", None) + if tool is None: + continue + metadata = getattr(tool, "metadata", None) or {} + if metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): + confirmation_tools.add(getattr(tool, "name", node_name)) + return confirmation_tools + def _is_middleware_node(self, node_name: str) -> bool: """Check if a node name represents a middleware node.""" return node_name in self._middleware_node_names diff --git a/tests/agent/tools/test_tool_node.py b/tests/agent/tools/test_tool_node.py index da2a72600..a4a3a1776 100644 --- a/tests/agent/tools/test_tool_node.py +++ b/tests/agent/tools/test_tool_node.py @@ -19,7 +19,11 @@ UiPathToolNode, create_tool_node, ) -from uipath_langchain.chat.hitl import ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE +from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, + CANCELLED_MESSAGE, + CONVERSATIONAL_APPROVED_TOOL_ARGS, +) class MockTool(BaseTool): @@ -597,3 +601,55 @@ async def test_async_approved_modified_args( msg = result["messages"][0] assert ARGS_MODIFIED_MESSAGE in msg.content assert "Async mock result: async edited" in msg.content + + @patch( + "uipath_langchain.chat.hitl.request_approval", + return_value={"input_text": "approved"}, + ) + def test_approved_attaches_approved_args_metadata( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Approved path attaches approved args in response_metadata.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert CONVERSATIONAL_APPROVED_TOOL_ARGS in msg.response_metadata + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "input_text": "approved" + } + + @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) + def test_cancelled_attaches_original_args_metadata( + self, mock_approval, confirmation_tool, confirmation_state + ): + """Cancelled path attaches original args in response_metadata.""" + node = UiPathToolNode(confirmation_tool) + + result = node._func(confirmation_state) + + assert result is not None + msg = result["messages"][0] + assert CONVERSATIONAL_APPROVED_TOOL_ARGS in msg.response_metadata + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "input_text": "test input" + } + + def test_no_confirmation_no_metadata(self): + """Non-confirmation tools don't get the approved args metadata.""" + tool = MockTool() # no confirmation metadata + node = UiPathToolNode(tool) + tool_call = { + "name": "mock_tool", + "args": {"input_text": "hello"}, + "id": "call_1", + } + state = MockState(messages=[AIMessage(content="go", tool_calls=[tool_call])]) + + result = node._func(state) + + assert result is not None + msg = result["messages"][0] + assert CONVERSATIONAL_APPROVED_TOOL_ARGS not in msg.response_metadata diff --git a/tests/chat/test_hitl.py b/tests/chat/test_hitl.py index 882f5c533..cd9e1b490 100644 --- a/tests/chat/test_hitl.py +++ b/tests/chat/test_hitl.py @@ -6,11 +6,9 @@ from langchain_core.tools import BaseTool from uipath_langchain.chat.hitl import ( - ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE, ConfirmationResult, check_tool_confirmation, - inject_confirmation_meta, request_approval, ) @@ -97,21 +95,6 @@ def test_approved_modified_args(self, mock_approval): assert call["args"] == {"query": "edited"} -class TestInjectConfirmationMeta: - """Tests for inject_confirmation_meta.""" - - def test_injects_meta_into_content(self): - msg = ToolMessage(content="some result", name="t", tool_call_id="c1") - inject_confirmation_meta(msg, ARGS_MODIFIED_MESSAGE) - assert ARGS_MODIFIED_MESSAGE in msg.content - assert "some result" in msg.content - - def test_wraps_content_as_json(self): - msg = ToolMessage(content="hello", name="t", tool_call_id="c1") - inject_confirmation_meta(msg, "test meta") - assert msg.content == '{"meta": "test meta", "result": hello}' - - class TestRequestApprovalTruthiness: """Tests for the truthiness fix in request_approval.""" diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 3eabe5e66..21d6ca2ec 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -1718,3 +1718,132 @@ def test_ai_message_with_media_citation(self): assert isinstance(source, UiPathConversationCitationSourceMedia) assert source.download_url == "https://r.com" assert source.page_number == "3" + + +class TestConfirmationToolDeferral: + """Tests for deferring startToolCall events for confirmation tools.""" + + @pytest.mark.asyncio + async def test_start_tool_call_skipped_for_confirmation_tool(self): + """AIMessageChunk with confirmation tool should NOT emit startToolCall.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.confirmation_tool_names = {"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) + + assert result is not None + tool_start_events = [ + e + 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 + + @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.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.confirmation_tool_names = {"other_tool"} + + first_chunk = AIMessageChunk( + content="", + id="msg-2", + tool_calls=[{"id": "tc-2", "name": "normal_tool", "args": {}}], + ) + await mapper.map_event(first_chunk) + + last_chunk = AIMessageChunk(content="", id="msg-2") + object.__setattr__(last_chunk, "chunk_position", "last") + result = await mapper.map_event(last_chunk) + + assert result is not None + tool_start_events = [ + e + for e in result + 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.start.tool_name == "normal_tool" + + @pytest.mark.asyncio + async def test_deferred_start_tool_call_emitted_from_tool_message(self): + """ToolMessage with approved_tool_args should trigger startToolCall before endToolCall.""" + from uipath_langchain.chat.hitl import CONVERSATIONAL_APPROVED_TOOL_ARGS + + storage = create_mock_storage() + storage.get_value.return_value = {"tc-3": "msg-3"} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.confirmation_tool_names = {"confirm_tool"} + + approved_args = {"query": "approved value"} + tool_msg = ToolMessage( + content='{"result": "ok"}', + tool_call_id="tc-3", + name="confirm_tool", + ) + tool_msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = approved_args + + result = await mapper.map_event(tool_msg) + + assert result is not None + # Should have: startToolCall, endToolCall, messageEnd + assert len(result) == 3 + + # First event: deferred startToolCall + start_event = result[0] + assert start_event.tool_call is not None + assert start_event.tool_call.start is not None + assert start_event.tool_call.start.tool_name == "confirm_tool" + assert start_event.tool_call.start.input == approved_args + + # Second event: endToolCall + end_event = result[1] + assert end_event.tool_call is not None + assert end_event.tool_call.end is not 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.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.confirmation_tool_names = {"confirm_tool"} + + first_chunk = AIMessageChunk( + content="", + id="msg-4", + tool_calls=[ + {"id": "tc-normal", "name": "normal_tool", "args": {"a": 1}}, + {"id": "tc-confirm", "name": "confirm_tool", "args": {"b": 2}}, + ], + ) + await mapper.map_event(first_chunk) + + last_chunk = AIMessageChunk(content="", id="msg-4") + object.__setattr__(last_chunk, "chunk_position", "last") + 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 diff --git a/uv.lock b/uv.lock index 3d52ade92..f59c4561d 100644 --- a/uv.lock +++ b/uv.lock @@ -3280,7 +3280,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.9.12" +version = "2.9.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3303,9 +3303,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/1b/308b3ef5e49796cb90f14b42e63be8aa9e47cb8f20cd004154845d9ced4b/uipath-2.9.12.tar.gz", hash = "sha256:a3c021243491634ba3f1fa0b58c698c360a8a728f369d56a3b3196246af87377", size = 2447228, upload-time = "2026-02-26T14:58:26.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/28/724817cdabea7a539a4e6c5c347caf7da1d7bc768cdc57925dcbd35e5031/uipath-2.9.13.tar.gz", hash = "sha256:31a2111e6a885a0ee38899d5528b3d1bfc7d79ee8d92c9a5b96521ed63ad5c3a", size = 2447296, upload-time = "2026-02-26T20:44:16.217Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/b3/0774ce5a8a992f5f3a04a66ce559e397d80935e5939e53c2a601bda15af1/uipath-2.9.12-py3-none-any.whl", hash = "sha256:973f99cdd1d33742d4b95654671563f42eaa39bcd33ef1a159ce0fbd3111b980", size = 352105, upload-time = "2026-02-26T14:58:25.016Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bf/4b846e5ad36a5b1b9998e71c7a93fd678a3306bfb843f743972f8ecc4ded/uipath-2.9.13-py3-none-any.whl", hash = "sha256:25d966a68cbe17105caa67b40a731de2c50842587f752a884fd6821300a007bd", size = 352183, upload-time = "2026-02-26T20:44:13.937Z" }, ] [[package]] @@ -3324,7 +3324,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.7.7" +version = "0.7.8" source = { editable = "." } dependencies = [ { name = "httpx" }, From 66d7f7ccae0ccdc775f10a0c54de87ca71cc4467 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:12:37 -0500 Subject: [PATCH 3/4] encapsulate --- .../agent/tools/tool_factory.py | 22 +++++----- src/uipath_langchain/agent/tools/tool_node.py | 30 ++++--------- src/uipath_langchain/chat/hitl.py | 31 ++++++++++---- tests/chat/test_hitl.py | 42 ++++++++++++++++++- 4 files changed, 83 insertions(+), 42 deletions(-) diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 4a771c0d2..f03c37600 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -51,21 +51,19 @@ async def create_tools_from_resources( ) tool = await _build_tool_for_resource(resource, llm) if tool is not None: - # propagate requireConversationalConfirmation to tool metadata (conversational agents only) + if agent.is_conversational: - tool_list = tool if isinstance(tool, list) else [tool] + if isinstance(tool, list): + continue + + # put REQUIRE_CONVERSATIONAL_CONFIRMATION to tool metadata props = getattr(resource, "properties", None) if props and getattr(props, REQUIRE_CONVERSATIONAL_CONFIRMATION, False): - # some resources (like mcp) can return a list of tools, so normalize to a list - for t in tool_list: - if t.metadata is None: - t.metadata = {} - t.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True - - if isinstance(tool, list): - tools.extend(tool) - else: - tools.append(tool) + if tool.metadata is None: + tool.metadata = {} + tool.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True + + tools.append(tool) return tools diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index ce4658005..461f537e5 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -21,11 +21,7 @@ extract_current_tool_call_index, find_latest_ai_message, ) -from uipath_langchain.chat.hitl import ( - ARGS_MODIFIED_MESSAGE, - CONVERSATIONAL_APPROVED_TOOL_ARGS, - check_tool_confirmation, -) +from uipath_langchain.chat.hitl import check_tool_confirmation # the type safety can be improved with generics ToolWrapperReturnType = dict[str, Any] | Command[Any] | None @@ -86,9 +82,6 @@ def _func(self, state: AgentGraphState) -> OutputType: confirmation = check_tool_confirmation(call, self.tool) if confirmation is not None and confirmation.cancelled: - confirmation.cancelled.response_metadata[ - CONVERSATIONAL_APPROVED_TOOL_ARGS - ] = call["args"] return self._process_result(call, confirmation.cancelled) try: @@ -100,11 +93,10 @@ def _func(self, state: AgentGraphState) -> OutputType: else: result = self.tool.invoke(call) output = self._process_result(call, result) - msg = self._get_tool_message(output) if confirmation is not None else None - if msg is not None: - msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = call["args"] - if confirmation.args_modified: - msg.content = f'{{"meta": "{ARGS_MODIFIED_MESSAGE}", "result": {msg.content}}}' + if confirmation is not None: + msg = self._get_tool_message(output) + if msg is not None: + confirmation.annotate_result(msg) return output except Exception as e: if self.handle_tool_errors: @@ -118,9 +110,6 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: confirmation = check_tool_confirmation(call, self.tool) if confirmation is not None and confirmation.cancelled: - confirmation.cancelled.response_metadata[ - CONVERSATIONAL_APPROVED_TOOL_ARGS - ] = call["args"] return self._process_result(call, confirmation.cancelled) try: @@ -132,11 +121,10 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: else: result = await self.tool.ainvoke(call) output = self._process_result(call, result) - msg = self._get_tool_message(output) if confirmation is not None else None - if msg is not None: - msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = call["args"] - if confirmation.args_modified: - msg.content = f'{{"meta": "{ARGS_MODIFIED_MESSAGE}", "result": {msg.content}}}' + if confirmation is not None: + msg = self._get_tool_message(output) + if msg is not None: + confirmation.annotate_result(msg) return output except Exception as e: if self.handle_tool_errors: diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index a07bf30f8..4e5660097 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -22,6 +22,18 @@ class ConfirmationResult(NamedTuple): cancelled: ToolMessage | None # ToolMessage if cancelled, None if approved args_modified: bool + approved_args: dict[str, Any] | None = None + + def annotate_result(self, msg: ToolMessage) -> None: + """Apply confirmation metadata to a tool result message.""" + if self.approved_args is not None: + msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = ( + self.approved_args + ) + if self.args_modified: + msg.content = ( + f'{{"meta": "{ARGS_MODIFIED_MESSAGE}", "result": {msg.content}}}' + ) def _patch_span_input(approved_args: dict[str, Any]) -> None: @@ -124,17 +136,20 @@ def check_tool_confirmation( {**original_args, "tool_call_id": call["id"]}, tool ) if approved_args is None: - return ConfirmationResult( - cancelled=ToolMessage( - content=CANCELLED_MESSAGE, - name=call["name"], - tool_call_id=call["id"], - ), - args_modified=False, + cancelled_msg = ToolMessage( + content=CANCELLED_MESSAGE, + name=call["name"], + tool_call_id=call["id"], + ) + cancelled_msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = ( + original_args ) + return ConfirmationResult(cancelled=cancelled_msg, args_modified=False) call["args"] = approved_args return ConfirmationResult( - cancelled=None, args_modified=approved_args != original_args + cancelled=None, + args_modified=approved_args != original_args, + approved_args=approved_args, ) diff --git a/tests/chat/test_hitl.py b/tests/chat/test_hitl.py index cd9e1b490..8a15a0b97 100644 --- a/tests/chat/test_hitl.py +++ b/tests/chat/test_hitl.py @@ -6,7 +6,9 @@ from langchain_core.tools import BaseTool from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE, + CONVERSATIONAL_APPROVED_TOOL_ARGS, ConfirmationResult, check_tool_confirmation, request_approval, @@ -48,7 +50,7 @@ def test_returns_none_when_flag_false(self): @patch("uipath_langchain.chat.hitl.request_approval", return_value=None) def test_cancelled_returns_tool_message(self, mock_approval): - """User rejects → ConfirmationResult with cancelled ToolMessage.""" + """User rejects → ConfirmationResult with cancelled ToolMessage and metadata.""" tool = MockTool(metadata={"require_conversational_confirmation": True}) call = _make_call() @@ -62,6 +64,9 @@ def test_cancelled_returns_tool_message(self, mock_approval): assert result.cancelled.name == "mock_tool" assert result.cancelled.tool_call_id == "call_1" assert result.args_modified is False + assert result.cancelled.response_metadata[ + CONVERSATIONAL_APPROVED_TOOL_ARGS + ] == {"query": "test"} @patch( "uipath_langchain.chat.hitl.request_approval", @@ -77,6 +82,7 @@ def test_approved_same_args(self, mock_approval): assert result is not None assert result.cancelled is None assert result.args_modified is False + assert result.approved_args == {"query": "test"} @patch( "uipath_langchain.chat.hitl.request_approval", @@ -92,9 +98,43 @@ def test_approved_modified_args(self, mock_approval): assert result is not None assert result.cancelled is None assert result.args_modified is True + assert result.approved_args == {"query": "edited"} assert call["args"] == {"query": "edited"} +class TestAnnotateResult: + """Tests for ConfirmationResult.annotate_result.""" + + def test_annotate_sets_metadata(self): + """annotate_result sets approved_args on response_metadata.""" + confirmation = ConfirmationResult( + cancelled=None, args_modified=False, approved_args={"query": "test"} + ) + msg = ToolMessage(content="result", tool_call_id="call_1") + + confirmation.annotate_result(msg) + + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "query": "test" + } + assert msg.content == "result" + + def test_annotate_wraps_content_when_modified(self): + """annotate_result wraps content when args were modified.""" + confirmation = ConfirmationResult( + cancelled=None, args_modified=True, approved_args={"query": "edited"} + ) + msg = ToolMessage(content="result", tool_call_id="call_1") + + confirmation.annotate_result(msg) + + assert msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] == { + "query": "edited" + } + assert ARGS_MODIFIED_MESSAGE in msg.content + assert "result" in msg.content + + class TestRequestApprovalTruthiness: """Tests for the truthiness fix in request_approval.""" From 4bec7b86f7badc77bd3d755064c4929dc98a0a3f Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:38:44 -0500 Subject: [PATCH 4/4] encapsulate better --- .../agent/tools/tool_factory.py | 26 +++++++++---------- src/uipath_langchain/agent/tools/tool_node.py | 17 ++---------- src/uipath_langchain/chat/hitl.py | 15 ++++++----- src/uipath_langchain/runtime/messages.py | 6 +++-- 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index f03c37600..8f7bc5ca3 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -51,19 +51,19 @@ async def create_tools_from_resources( ) tool = await _build_tool_for_resource(resource, llm) if tool is not None: - - if agent.is_conversational: - if isinstance(tool, list): - continue - - # put REQUIRE_CONVERSATIONAL_CONFIRMATION to tool metadata - props = getattr(resource, "properties", None) - if props and getattr(props, REQUIRE_CONVERSATIONAL_CONFIRMATION, False): - if tool.metadata is None: - tool.metadata = {} - tool.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True - - tools.append(tool) + if isinstance(tool, list): + tools.extend(tool) + else: + tools.append(tool) + + if agent.is_conversational: + props = getattr(resource, "properties", None) + if props and getattr( + props, REQUIRE_CONVERSATIONAL_CONFIRMATION, False + ): + if tool.metadata is None: + tool.metadata = {} + tool.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True return tools diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 461f537e5..3fc4b5f12 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -94,9 +94,7 @@ def _func(self, state: AgentGraphState) -> OutputType: result = self.tool.invoke(call) output = self._process_result(call, result) if confirmation is not None: - msg = self._get_tool_message(output) - if msg is not None: - confirmation.annotate_result(msg) + confirmation.annotate_result(output) return output except Exception as e: if self.handle_tool_errors: @@ -122,9 +120,7 @@ async def _afunc(self, state: AgentGraphState) -> OutputType: result = await self.tool.ainvoke(call) output = self._process_result(call, result) if confirmation is not None: - msg = self._get_tool_message(output) - if msg is not None: - confirmation.annotate_result(msg) + confirmation.annotate_result(output) return output except Exception as e: if self.handle_tool_errors: @@ -181,15 +177,6 @@ def _process_result( ) return {"messages": [message]} - @staticmethod - def _get_tool_message(output: OutputType) -> ToolMessage | None: - """Extract the ToolMessage from a processed output, if present.""" - if isinstance(output, dict): - messages = output.get("messages") - if messages: - return messages[0] - return None - @staticmethod def _filter_result(command: Command[Any]) -> None: """Strip NO_CONTENT markers from ToolMessages embedded in a Command.""" diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 4e5660097..26d6c1c37 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -24,8 +24,15 @@ class ConfirmationResult(NamedTuple): args_modified: bool approved_args: dict[str, Any] | None = None - def annotate_result(self, msg: ToolMessage) -> None: + def annotate_result(self, output: dict[str, Any] | Any) -> None: """Apply confirmation metadata to a tool result message.""" + msg = None + if isinstance(output, dict): + messages = output.get("messages") + if messages: + msg = messages[0] + if msg is None: + return if self.approved_args is not None: msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = ( self.approved_args @@ -122,12 +129,6 @@ def request_approval( def check_tool_confirmation( call: ToolCall, tool: BaseTool ) -> ConfirmationResult | None: - """Check if a tool requires confirmation and request approval if so. - - Returns None if no confirmation is needed. - Returns ConfirmationResult with approved_args=None if cancelled. - Returns ConfirmationResult with approved_args and args_modified flag if approved. - """ if not (tool.metadata and tool.metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION)): return None diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index f7256f5f4..7c4bc2d67 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -60,7 +60,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None """Initialize the mapper with empty state.""" self.runtime_id = runtime_id self.storage = storage - self.confirmation_tool_names: set[str] = set() + self.confirmation_tool_names: set[str] = set[str]() self.current_message: AIMessageChunk self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() @@ -392,7 +392,9 @@ async def map_current_message_to_start_tool_call_events(self): tool_call_id_to_message_id_map[tool_call_id] = ( self.current_message.id ) - if tool_call["name"] not in self.confirmation_tool_names: + + if tool_call["name"] in self.confirmation_tool_names: + # defer tool call for HITL events.append( self.map_tool_call_to_tool_call_start_event( self.current_message.id, tool_call