diff --git a/pyproject.toml b/pyproject.toml index 744ebc626..71281ef99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.9.26" +version = "0.9.27" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath_langchain/agent/tools/__init__.py b/src/uipath_langchain/agent/tools/__init__.py index 448d13526..701e73c99 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 ( + ConversationalToolRunnableCallable, ToolWrapperMixin, UiPathToolNode, create_tool_node, @@ -32,6 +33,7 @@ "create_ixp_extraction_tool", "create_ixp_escalation_tool", "UiPathToolNode", + "ConversationalToolRunnableCallable", "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..00269f5da 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,27 @@ 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 ConversationalToolRunnableCallable( + func=_func, afunc=_afunc, name=tool_name, tool=tool + ) + return RunnableCallable(func=_func, afunc=_afunc, name=tool_name) +class ConversationalToolRunnableCallable(RunnableCallable): + """Preserves a reference to the underlying BaseTool for conversational tool confirmation.""" + + def __init__(self, *, func: Any, afunc: Any, 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 f8a1e6ee5..8f12fa06a 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() @@ -340,7 +340,13 @@ async def map_ai_message_chunk_to_events( ) 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 the first chunk — it's already assigned as current_message above, + # so accumulating it with itself would duplicate fields via string concat + # (e.g. tool name "search_web" becomes "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: @@ -425,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( @@ -531,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, @@ -541,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, ), ), ) @@ -658,7 +674,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 @@ -708,7 +724,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..24407b958 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -29,7 +29,7 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema -from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION +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 +65,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 +488,36 @@ def _detect_middleware_nodes(self) -> set[str]: return middleware_nodes - def _get_tool_names_requiring_confirmation(self) -> set[str]: - names: set[str] = set() + 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_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: + bound = getattr(node_spec, "bound", None) + if bound is None: continue - metadata = getattr(tool, "metadata", None) or {} - if metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): - names.add(getattr(tool, "name", node_name)) - return names + + # Coded agents: one tool per node + tool = getattr(bound, "tool", None) + if tool is not None: + schema = get_confirmation_schema(tool) + if schema is not None: + schemas[getattr(tool, "name", node_name)] = schema + continue + + # Low-code agents: multiple tools in one node + tools_by_name = getattr(bound, "tools_by_name", None) + if isinstance(tools_by_name, dict): + for name, tool in tools_by_name.items(): + schema = get_confirmation_schema(tool) + if schema is not None: + schemas[str(getattr(tool, "name", 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 8b3bbecc2..a7e357ad5 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -1996,18 +1996,31 @@ 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.""" +def _require_confirmation_field_available() -> bool: + """Return True if the installed uipath-core exposes the require_confirmation field.""" + try: + from uipath.core.chat.tool import ( + UiPathConversationToolCallStartEvent, + ) + + return hasattr(UiPathConversationToolCallStartEvent, "require_confirmation") + except Exception: + return False + + +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.""" + if not _require_confirmation_field_available(): + pytest.skip("requires uipath-core>=0.5.12 with require_confirmation field") 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", @@ -2015,7 +2028,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) @@ -2026,15 +2038,22 @@ 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 # type: ignore[attr-defined] @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.""" + if not _require_confirmation_field_available(): + pytest.skip("requires uipath-core>=0.5.12 with require_confirmation field") 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="", @@ -2054,48 +2073,20 @@ 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 # type: ignore[attr-defined] @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.""" + if not _require_confirmation_field_available(): + pytest.skip("requires uipath-core>=0.5.12 with require_confirmation field") 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="", @@ -2112,11 +2103,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 # type: ignore[attr-defined] + assert tool_starts["confirm_tool"].require_confirmation is True # type: ignore[attr-defined] diff --git a/tests/runtime/test_tool_confirmation_discovery.py b/tests/runtime/test_tool_confirmation_discovery.py new file mode 100644 index 000000000..90116e652 --- /dev/null +++ b/tests/runtime/test_tool_confirmation_discovery.py @@ -0,0 +1,89 @@ +"""Tests that _get_tool_confirmation_info discovers confirmation tools through ConversationalToolRunnableCallable 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 7d8a69258..e7e8503f5 100644 --- a/uv.lock +++ b/uv.lock @@ -3393,7 +3393,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.42" +version = "2.10.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -3416,9 +3416,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/04/ad178f330794c340f8def51c7f5cd7209878413501a2449d2d292d3e8c4d/uipath-2.10.42.tar.gz", hash = "sha256:f40e0898554e19226939bd9257250b02e611872602fd56d8a2392baa8d23f282", size = 2917583, upload-time = "2026-04-06T11:26:54.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/bd/d6c288f2a3df52238324eb75de5096f309e0591c8d84d0f714e149cf3e8e/uipath-2.10.48.tar.gz", hash = "sha256:4f3289855aa312982e09dcd62299f7b51caccefd6b1daf61aef23f89018ec1e0", size = 2917316, upload-time = "2026-04-10T00:00:37.66Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/49/3a56e2ae5c67ad1b4b48546c9782ab1711289375fe5b7733a12c79437d8a/uipath-2.10.42-py3-none-any.whl", hash = "sha256:73b68633ea6c2c8a84f104fcdb372cca667e7e667a121d0ce7a4e55240bd997a", size = 380466, upload-time = "2026-04-06T11:26:52.095Z" }, + { url = "https://files.pythonhosted.org/packages/76/cd/2c3460c81ff55a878d682d89c846c1ed50c6e28b6c8982e5d7b37609822d/uipath-2.10.48-py3-none-any.whl", hash = "sha256:3ea380da56b5086682ff8be1f798dc571691c031bf3f12f740d49616c9686105", size = 381169, upload-time = "2026-04-10T00:00:35.757Z" }, ] [[package]] @@ -3437,7 +3437,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.9.26" +version = "0.9.27" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -3533,7 +3533,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.25" +version = "0.1.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3543,9 +3543,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/68/9bc2c0c2cbaea0beaeb84f406887717491fefd10b6f1de64db96d69ac188/uipath_platform-0.1.25.tar.gz", hash = "sha256:e390df460441b860c1c4d1f544e44e84bbdc392c4b95bbd4e672028be78cf34e", size = 313914, upload-time = "2026-04-10T07:19:21.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/2a/8fc257bb4752b863cb5ab573c2ebe92dd2f5ff966c59415fc631cb88c415/uipath_platform-0.1.27.tar.gz", hash = "sha256:4b49c1e3934d2bb287e9c9415573b1d89800562f30d66e9ad814e85c9bec6c74", size = 315300, upload-time = "2026-04-14T11:46:24.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/e8/013e096e23cc1fc3bffbec6eceee3f0c1a1fef2c0ec6796caf36099140df/uipath_platform-0.1.25-py3-none-any.whl", hash = "sha256:5e45759f8ecd45be7d467d93cff7666785bb598f8de09d11841ca0a86af974da", size = 205317, upload-time = "2026-04-10T07:19:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/213cdcf8ccadb0334abf5336a90cbf6e0662d0f1d96e587046efed480ef8/uipath_platform-0.1.27-py3-none-any.whl", hash = "sha256:8d3d325df83c63164effa66f56b95add7e12729e9f85a4f0d0e7d84b04654cce", size = 205986, upload-time = "2026-04-14T11:46:22.586Z" }, ] [[package]]