diff --git a/pyproject.toml b/pyproject.toml index 6b9152464..bb9ab62f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath-langchain" -version = "0.10.7" +version = "0.10.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" dependencies = [ - "uipath>=2.10.53, <2.11.0", - "uipath-core>=0.5.2, <0.6.0", + "uipath>=2.10.57, <2.11.0", + "uipath-core>=0.5.13, <0.6.0", "uipath-platform>=0.1.36, <0.2.0", "uipath-runtime>=0.10.0, <0.11.0", "langgraph>=1.1.8, <2.0.0", diff --git a/src/uipath_langchain/agent/tools/__init__.py b/src/uipath_langchain/agent/tools/__init__.py index 448d13526..7c6a7e37e 100644 --- a/src/uipath_langchain/agent/tools/__init__.py +++ b/src/uipath_langchain/agent/tools/__init__.py @@ -12,6 +12,7 @@ create_tools_from_resources, ) from .tool_node import ( + RunnableCallableWithTool, ToolWrapperMixin, UiPathToolNode, create_tool_node, @@ -32,6 +33,7 @@ "create_ixp_extraction_tool", "create_ixp_escalation_tool", "UiPathToolNode", + "RunnableCallableWithTool", "ToolWrapperMixin", "wrap_tools_with_error_handling", ] diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 092d994f7..88480c5a3 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -22,7 +22,10 @@ extract_current_tool_call_index, find_latest_ai_message, ) -from uipath_langchain.chat.hitl import request_conversational_tool_confirmation +from uipath_langchain.chat.hitl import ( + REQUIRE_CONVERSATIONAL_CONFIRMATION, + request_conversational_tool_confirmation, +) # the type safety can be improved with generics ToolWrapperReturnType = dict[str, Any] | Command[Any] | None @@ -274,9 +277,34 @@ async def _afunc(state: AgentGraphState) -> OutputType: raise return result + tool = getattr(tool_node, "tool", None) + + # Preserve tool ref so the runtime can discover which tools need confirmation + # (see runtime.py _get_tool_confirmation_info) + metadata = getattr(tool, "metadata", None) or {} + if isinstance(tool, BaseTool) and metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): + return RunnableCallableWithTool( + func=_func, afunc=_afunc, name=tool_name, tool=tool + ) + return RunnableCallable(func=_func, afunc=_afunc, name=tool_name) +class RunnableCallableWithTool(RunnableCallable): + """A RunnableCallable that preserves a reference to its underlying BaseTool.""" + + def __init__( + self, + *, + func: Callable[..., Any] | None, + afunc: Callable[..., Awaitable[Any]] | None, + name: str, + tool: BaseTool, + ): + super().__init__(func=func, afunc=afunc, name=name) + self.tool = tool + + class ToolWrapperMixin: wrapper: ToolWrapperType | None = None awrapper: AsyncToolWrapperType | None = None diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 228d1b365..72a99800e 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -7,18 +7,43 @@ 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 uipath.core.chat import ( - UiPathConversationToolCallConfirmationValue, -) +from uipath.core.chat import UiPathConversationToolCallConfirmationEvent 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" +def _wrap_with_args_modified_meta(result: Any, approved_args: dict[str, Any]) -> str: + """Wrap a tool result with metadata indicating the user modified the args.""" + try: + result_value = json.loads(result) if isinstance(result, str) else result + except (json.JSONDecodeError, TypeError): + result_value = result + return json.dumps( + { + "meta": { + "message": ARGS_MODIFIED_MESSAGE, + "executed_args": approved_args, + }, + "result": result_value, + } + ) + + +def get_confirmation_schema(tool: Any) -> dict[str, Any] | None: + """Return the JSON input schema if this tool requires confirmation, else None.""" + metadata = getattr(tool, "metadata", None) or {} + if not metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): + return None + tool_call_schema = getattr(tool, "tool_call_schema", None) + return tool_call_schema.model_json_schema() if tool_call_schema is not None else {} + + class ConfirmationResult(NamedTuple): """Result of a tool confirmation check.""" @@ -47,20 +72,8 @@ def annotate_result(self, output: dict[str, Any] | Any) -> None: msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = ( self.approved_args ) - if self.args_modified: - try: - result_value = json.loads(msg.content) - except (json.JSONDecodeError, TypeError): - result_value = msg.content - msg.content = json.dumps( - { - "meta": { - "args_modified_by_user": True, - "executed_args": self.approved_args, - }, - "result": result_value, - } - ) + if self.args_modified and self.approved_args is not None: + msg.content = _wrap_with_args_modified_meta(msg.content, self.approved_args) def _patch_span_input(approved_args: dict[str, Any]) -> None: @@ -113,39 +126,24 @@ def request_approval( """ tool_call_id: str = tool_args.pop("tool_call_id") - input_schema: dict[str, Any] = {} - tool_call_schema = getattr( - tool, "tool_call_schema", None - ) # doesn't include InjectedToolCallId (tool id from claude/oai/etc.) - if tool_call_schema is not None: - input_schema = tool_call_schema.model_json_schema() - @durable_interrupt def ask_confirmation(): - return UiPathConversationToolCallConfirmationValue( - tool_call_id=tool_call_id, - tool_name=tool.name, - input_schema=input_schema, - input_value=tool_args, - ) + return { + "tool_call_id": tool_call_id, + "tool_name": tool.name, + "input": tool_args, + } response = ask_confirmation() - # The resume payload from CAS has shape: - # {"type": "uipath_cas_tool_call_confirmation", - # "value": {"approved": bool, "input": }} if not isinstance(response, dict): return tool_args - confirmation = response.get("value", response) - if not confirmation.get("approved", True): + confirmation = UiPathConversationToolCallConfirmationEvent.model_validate(response) + if not confirmation.approved: return None - return ( - confirmation.get("input") - if confirmation.get("input") is not None - else tool_args - ) + return confirmation.input if confirmation.input is not None else tool_args # for conversational low code agents @@ -200,8 +198,15 @@ def wrapper(**tool_args: Any) -> Any: if approved_args is None: return json.dumps({"meta": CANCELLED_MESSAGE}) + args_modified = approved_args != tool_args + _patch_span_input(approved_args) - return fn(**approved_args) + result = fn(**approved_args) + + if args_modified: + return _wrap_with_args_modified_meta(result, approved_args) + + return result # rewrite the signature: e.g. (query: str) -> (query: str, *, tool_call_id: str) original_sig = inspect.signature(fn) @@ -234,6 +239,10 @@ def wrapper(**tool_args: Any) -> Any: return_direct=return_direct, ) + if result.metadata is None: + result.metadata = {} + result.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True + _created_tool.append(result) return result diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index b8885edbb..5d7d63aa8 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -59,7 +59,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None self.runtime_id = runtime_id self.storage = storage self.current_message: AIMessageChunk | AIMessage - self.tool_names_requiring_confirmation: set[str] = set() + self.tools_requiring_confirmation: dict[str, Any] = {} self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() self._citation_stream_processor = CitationStreamProcessor() @@ -339,8 +339,12 @@ async def map_ai_message_chunk_to_events( self._chunk_to_message_event(message.id, chunk) ) case "tool_call_chunk": - # Accumulate the message chunk. Note that we assume no interweaving of AIMessage and AIMessageChunks for a given message. - if isinstance(self.current_message, AIMessageChunk): + # Skip self-merge: OpenAI's chunk #1 carries the tool name, and adding + # a chunk to itself doubles string fields ("search_web" -> "search_websearch_web"). + if ( + isinstance(self.current_message, AIMessageChunk) + and self.current_message is not message + ): self.current_message = self.current_message + message elif isinstance(message.content, str) and message.content: @@ -427,16 +431,19 @@ 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 - ) + tool_name = tool_call["name"] + require_confirmation = ( + tool_name in self.tools_requiring_confirmation + ) + input_schema = self.tools_requiring_confirmation.get(tool_name) + events.append( + self.map_tool_call_to_tool_call_start_event( + self.current_message.id, + tool_call, + require_confirmation=require_confirmation or None, + input_schema=input_schema, ) + ) if self.storage is not None: await self.storage.set_value( @@ -533,7 +540,12 @@ async def get_message_id_for_tool_call( return message_id, is_last def map_tool_call_to_tool_call_start_event( - self, message_id: str, tool_call: ToolCall + self, + message_id: str, + tool_call: ToolCall, + *, + require_confirmation: bool | None = None, + input_schema: Any | None = None, ) -> UiPathConversationMessageEvent: return UiPathConversationMessageEvent( message_id=message_id, @@ -543,6 +555,8 @@ def map_tool_call_to_tool_call_start_event( tool_name=tool_call["name"], timestamp=self.get_timestamp(), input=tool_call["args"], + require_confirmation=require_confirmation, + input_schema=input_schema, ), ), ) @@ -667,7 +681,7 @@ def _map_langchain_human_message_to_uipath_message_data( ) return UiPathConversationMessageData( - role="user", content_parts=content_parts, tool_calls=[], interrupts=[] + role="user", content_parts=content_parts, tool_calls=[] ) @staticmethod @@ -717,7 +731,6 @@ def _map_langchain_ai_message_to_uipath_message_data( role="assistant", content_parts=content_parts, tool_calls=uipath_tool_calls, - interrupts=[], ) diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index feb327018..da8d90918 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -5,6 +5,7 @@ from langchain_core.callbacks import BaseCallbackHandler from langchain_core.runnables.config import RunnableConfig +from langchain_core.tools import BaseTool from langgraph.errors import EmptyInputError, GraphRecursionError, InvalidUpdateError from langgraph.graph.state import CompiledStateGraph from langgraph.types import Command, Interrupt, StateSnapshot @@ -29,7 +30,8 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema -from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION +from uipath_langchain.agent.tools.tool_node import RunnableCallableWithTool +from uipath_langchain.chat.hitl import get_confirmation_schema 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 @@ -65,9 +67,7 @@ def __init__( self.entrypoint: str | None = entrypoint self.callbacks: list[BaseCallbackHandler] = callbacks or [] self.chat = UiPathChatMessagesMapper(self.runtime_id, storage) - self.chat.tool_names_requiring_confirmation = ( - self._get_tool_names_requiring_confirmation() - ) + self.chat.tools_requiring_confirmation = self._get_tool_confirmation_info() self._middleware_node_names: set[str] = self._detect_middleware_nodes() async def execute( @@ -490,17 +490,37 @@ def _detect_middleware_nodes(self) -> set[str]: return middleware_nodes - def _get_tool_names_requiring_confirmation(self) -> set[str]: - names: set[str] = set() - for node_name, node_spec in self.graph.nodes.items(): - # langgraph's processing node.bound -> runnable.tool -> baseTool (if tool node) - tool = getattr(getattr(node_spec, "bound", None), "tool", None) - if tool is None: + def _get_tool_confirmation_info(self) -> dict[str, Any]: + """Build {tool_name: input_schema} for tools requiring confirmation. + + Walks compiled graph nodes once at runtime init. This is needed because coded agents + (create_agent) export a compiled graph as the only artifact — there's no side channel + to pass confirmation metadata from the build step to the runtime. + """ + schemas: dict[str, Any] = {} + for node_spec in self.graph.nodes.values(): + bound = getattr(node_spec, "bound", None) + if bound is None: + continue + + # Coded agents: one tool per node + if isinstance(bound, RunnableCallableWithTool): + schema = get_confirmation_schema(bound.tool) + if schema is not None: + schemas[bound.tool.name] = schema continue - metadata = getattr(tool, "metadata", None) or {} - if metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): - names.add(getattr(tool, "name", node_name)) - return names + + # Low-code agents: multiple tools in one node + tools_by_name = getattr(bound, "tools_by_name", None) + if isinstance(tools_by_name, dict): + for tool in tools_by_name.values(): + if not isinstance(tool, BaseTool): + continue + schema = get_confirmation_schema(tool) + if schema is not None: + schemas[tool.name] = schema + + return schemas def _is_middleware_node(self, node_name: str) -> bool: """Check if a node name represents a middleware node.""" diff --git a/tests/agent/tools/test_tool_node.py b/tests/agent/tools/test_tool_node.py index 23d7e60c1..1212e2be8 100644 --- a/tests/agent/tools/test_tool_node.py +++ b/tests/agent/tools/test_tool_node.py @@ -23,6 +23,7 @@ wrap_tools_with_error_handling, ) from uipath_langchain.chat.hitl import ( + ARGS_MODIFIED_MESSAGE, CANCELLED_MESSAGE, CONVERSATIONAL_APPROVED_TOOL_ARGS, ) @@ -507,7 +508,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( @@ -528,7 +529,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"] @@ -564,7 +565,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..d0fb88d23 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" @@ -149,7 +150,7 @@ class TestRequestApprovalTruthiness: @patch("uipath_langchain._utils.durable_interrupt.decorator.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": {}}} + mock_interrupt.return_value = {"approved": True, "input": {}} tool = MockTool() result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) assert result == {} @@ -157,7 +158,7 @@ def test_empty_dict_input_preserved(self, mock_interrupt): @patch("uipath_langchain._utils.durable_interrupt.decorator.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": []}} + mock_interrupt.return_value = {"approved": True, "input": []} tool = MockTool() result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) assert result == [] @@ -165,7 +166,7 @@ def test_empty_list_input_preserved(self, mock_interrupt): @patch("uipath_langchain._utils.durable_interrupt.decorator.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}} + mock_interrupt.return_value = {"approved": True, "input": None} tool = MockTool() result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) assert result == {"query": "test"} @@ -173,7 +174,7 @@ def test_none_input_falls_back_to_original(self, mock_interrupt): @patch("uipath_langchain._utils.durable_interrupt.decorator.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}} + mock_interrupt.return_value = {"approved": True} tool = MockTool() result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) assert result == {"query": "test"} @@ -181,7 +182,7 @@ def test_missing_input_falls_back_to_original(self, mock_interrupt): @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") def test_rejected_returns_none(self, mock_interrupt): """Rejected approval returns None.""" - mock_interrupt.return_value = {"value": {"approved": False}} + mock_interrupt.return_value = {"approved": False} tool = MockTool() result = request_approval({"query": "test", "tool_call_id": "c1"}, tool) assert result is None diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index fd2a3c632..d2bdee792 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -2004,18 +2004,17 @@ async def test_pii_masked_response_full_flow(self): assert result[-1].end is not None -class TestConfirmationToolDeferral: - """Tests for deferring startToolCall events for confirmation tools.""" +class TestToolCallConfirmation: + """Tests for requireConfirmation flag on startToolCall events.""" @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"} + mapper.tools_requiring_confirmation = {"confirm_tool": {}} - # First chunk starts the message with a confirmation tool call first_chunk = AIMessageChunk( content="", id="msg-1", @@ -2023,7 +2022,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) @@ -2034,15 +2032,20 @@ 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.require_confirmation is 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) - mapper.tool_names_requiring_confirmation = {"other_tool"} + mapper.tools_requiring_confirmation = {"other_tool": {}} first_chunk = AIMessageChunk( content="", @@ -2062,48 +2065,18 @@ 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.require_confirmation 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) - mapper.tool_names_requiring_confirmation = {"confirm_tool"} + mapper.tools_requiring_confirmation = {"confirm_tool": {}} first_chunk = AIMessageChunk( content="", @@ -2120,11 +2093,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"].require_confirmation is None + assert tool_starts["confirm_tool"].require_confirmation is True diff --git a/tests/runtime/test_tool_confirmation_discovery.py b/tests/runtime/test_tool_confirmation_discovery.py new file mode 100644 index 000000000..9599aea47 --- /dev/null +++ b/tests/runtime/test_tool_confirmation_discovery.py @@ -0,0 +1,89 @@ +"""Tests that _get_tool_confirmation_info discovers confirmation tools through RunnableCallableWithTool wrappers. + +This is the integration guard against silent regressions: if LangGraph changes its +compiled-graph node structure, or if a new wrapping layer forgets to preserve the +BaseTool reference, these tests will fail. +""" + +from typing import Any + +from langchain_core.tools import BaseTool +from langgraph.constants import END, START +from langgraph.graph import StateGraph +from pydantic import BaseModel, Field + +from uipath_langchain.agent.tools.tool_node import ( + UiPathToolNode, + wrap_tools_with_error_handling, +) +from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + + +class _ConfirmableInput(BaseModel): + query: str = Field(description="The query to confirm") + + +class _ConfirmableTool(BaseTool): + name: str = "needs_confirmation" + description: str = "A tool that requires user confirmation" + args_schema: type[BaseModel] = _ConfirmableInput + metadata: dict[str, Any] = {REQUIRE_CONVERSATIONAL_CONFIRMATION: True} + + def _run(self, query: str) -> str: + return f"confirmed: {query}" + + +class _NormalTool(BaseTool): + name: str = "no_confirmation" + description: str = "A normal tool" + + def _run(self) -> str: + return "done" + + +class _MinimalState(BaseModel): + value: str = "" + + +def _compile_graph_with_wrapped_tools(tools: list[BaseTool]): + """Build and compile a minimal graph with tools wrapped through the standard pipeline.""" + tool_nodes = {t.name: UiPathToolNode(t) for t in tools} + wrapped = wrap_tools_with_error_handling(tool_nodes) + + builder: StateGraph[_MinimalState] = StateGraph(_MinimalState) + names = list(wrapped.keys()) + for name, node in wrapped.items(): + builder.add_node(name, node) + + # Wire START → first tool → END (graph must be connected to compile) + builder.add_edge(START, names[0]) + for i in range(len(names) - 1): + builder.add_edge(names[i], names[i + 1]) + builder.add_edge(names[-1], END) + + return builder.compile() + + +class TestToolConfirmationDiscovery: + def test_discovers_confirmation_tool_through_wrapper(self): + graph = _compile_graph_with_wrapped_tools([_ConfirmableTool(), _NormalTool()]) + runtime = UiPathLangGraphRuntime(graph) + + schemas = runtime.chat.tools_requiring_confirmation + assert "needs_confirmation" in schemas + assert "no_confirmation" not in schemas + + def test_schema_contains_input_properties(self): + graph = _compile_graph_with_wrapped_tools([_ConfirmableTool()]) + runtime = UiPathLangGraphRuntime(graph) + + schema = runtime.chat.tools_requiring_confirmation["needs_confirmation"] + assert "properties" in schema + assert "query" in schema["properties"] + + def test_empty_when_no_confirmation_tools(self): + graph = _compile_graph_with_wrapped_tools([_NormalTool()]) + runtime = UiPathLangGraphRuntime(graph) + + assert runtime.chat.tools_requiring_confirmation == {} diff --git a/uv.lock b/uv.lock index c279d3870..476b4d6a4 100644 --- a/uv.lock +++ b/uv.lock @@ -4331,7 +4331,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.53" +version = "2.10.57" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4354,28 +4354,28 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/df/af004a9fed96607c87c86b2634b12a82b04a850fc9072e9aed4fe9aecec8/uipath-2.10.53.tar.gz", hash = "sha256:a26feb41ccc5998f1a487d990a2715cfc7d64edb700ca1f4d4dfb02d7de5bd06", size = 2929086, upload-time = "2026-04-22T15:50:36.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/b6/34e82e3895151cfbb923c135fb117ee98d8a4d2156f26fe94a8e2ff3f206/uipath-2.10.57.tar.gz", hash = "sha256:8b94dd42b6b96abb71b0904f540f0fb4591c67cb1df37ce7539cbf53b9961aa6", size = 2932994, upload-time = "2026-04-27T17:35:00.572Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/f7/f086334a49ac1d29c8cb3f016e568e0532a5e47a72699cc7918a105c6842/uipath-2.10.53-py3-none-any.whl", hash = "sha256:cd31d9902656db448cf17a194a8a6a3b3e0e8d9b46c3a87a4f494f74830212b7", size = 387892, upload-time = "2026-04-22T15:50:34.174Z" }, + { url = "https://files.pythonhosted.org/packages/1c/41/543a35b20f55c7dd098e0a019d8ac106510e44210d3e93677605d861083f/uipath-2.10.57-py3-none-any.whl", hash = "sha256:b26ceac02764910f1f1b8f8b5ab65efd220ff954414b2e6c2d7e8f84fcef5e32", size = 389193, upload-time = "2026-04-27T17:34:58.412Z" }, ] [[package]] name = "uipath-core" -version = "0.5.12" +version = "0.5.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/fd/a90278f4d7bb52b4e40a04dc95b792b49612202d0b816f7785885dece0ce/uipath_core-0.5.12.tar.gz", hash = "sha256:e13a54caac07bf19b5bd9de699da4ba8012eb0f6a9e667c4f8007e1b1aa51a17", size = 117249, upload-time = "2026-04-21T14:38:05.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/5c/96f4e9f451522fb10ae5977797388c0dbac7b2e8efc458ba734dd3e41127/uipath_core-0.5.13.tar.gz", hash = "sha256:680a867ac32606e9d1829ea59933f4e84b2e35cd0aa9d64f1ac2257b9b1fae35", size = 116756, upload-time = "2026-04-27T17:33:13.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/80/6c522e0e548e35764403f4ba065c1c2537ceb56a50670c1c95bf0d0618ba/uipath_core-0.5.12-py3-none-any.whl", hash = "sha256:81194ae704a53e0a0a2cde62e817f346b3750cc036f40a9d88fb1cc24c0f74c0", size = 43844, upload-time = "2026-04-21T14:38:03.788Z" }, + { url = "https://files.pythonhosted.org/packages/38/41/141a58a9c60ee022a98f7af846b36ba010fc199a1b02bbc95a177ced71f0/uipath_core-0.5.13-py3-none-any.whl", hash = "sha256:189004284f59151983cb6f0c29902e6dca87d42840ea26353a0905a0a3371edc", size = 42927, upload-time = "2026-04-27T17:33:12.427Z" }, ] [[package]] name = "uipath-langchain" -version = "0.10.7" +version = "0.10.8" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4450,8 +4450,8 @@ requires-dist = [ { name = "pillow", specifier = ">=12.1.1" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.10.53,<2.11.0" }, - { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, + { name = "uipath", specifier = ">=2.10.57,<2.11.0" }, + { name = "uipath-core", specifier = ">=0.5.13,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.10.0,<1.11.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.10.0,<1.11.0" }, { name = "uipath-langchain-client", extras = ["bedrock"], marker = "extra == 'bedrock'", specifier = ">=1.10.0,<1.11.0" }, @@ -4560,14 +4560,14 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.10.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/64/69462ee01a5607ce36b1fa152c52ac72fb28abe0aa049394406fc0b31525/uipath_runtime-0.10.0.tar.gz", hash = "sha256:d27d58e2252f506c8c0e00f814b37c3863150e8ffcde8e4c6ab14bd98febd3df", size = 139626, upload-time = "2026-03-24T19:42:43.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/87/2e625219b3364a7153549e6056bce41d2050725ed0844f2711c414a872c0/uipath_runtime-0.10.1.tar.gz", hash = "sha256:9ed1bdb6737ad64cc5bb7ef0c8466dbae8ca010858ecd856818396ea264eb3d5", size = 141189, upload-time = "2026-04-23T11:34:53.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/9c0e97a078b96e4d3742ea3515cb30886b08579cd08077cd42a159adf70d/uipath_runtime-0.10.0-py3-none-any.whl", hash = "sha256:4f52df0b56f54e70fcf34fbf74e223d02b97b5a6fd6d8f64bc06782bb5484b07", size = 42097, upload-time = "2026-03-24T19:42:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/ad/41/bc3465ee89dd01f8a9045d7d22d0f0927c0d437242eeded8d3d5b33f50ed/uipath_runtime-0.10.1-py3-none-any.whl", hash = "sha256:f04483db92ee7683513762a79bf48c229c7133d5adc7fef10ea5eaa4c7ce9b29", size = 43057, upload-time = "2026-04-23T11:34:51.781Z" }, ] [[package]]