From 4ceca3bf0b6cbdb84ccb26d4d7fe381763937cee Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 1 May 2026 17:13:25 -0400 Subject: [PATCH 01/12] feat: add client side tools to mapper and runtime --- .../agent/tools/client_side_tool.py | 95 +++++++++++++++++++ .../agent/tools/tool_factory.py | 5 + src/uipath_langchain/chat/hitl.py | 6 ++ src/uipath_langchain/runtime/messages.py | 57 ++++++++--- src/uipath_langchain/runtime/runtime.py | 30 ++++++ 5 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 src/uipath_langchain/agent/tools/client_side_tool.py diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py new file mode 100644 index 000000000..9178915a6 --- /dev/null +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -0,0 +1,95 @@ +"""Factory for creating client-side tools that execute on the client SDK.""" + +import inspect +import json +from logging import getLogger +from typing import Annotated, Any + +from langchain_core.messages import ToolMessage +from langchain_core.tools import InjectedToolCallId, StructuredTool +from uipath.agent.models.agent import AgentClientSideToolResourceConfig +from uipath.eval.mocks import mockable + +from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( + create_model as create_model_from_schema, +) + +from .utils import sanitize_tool_name + +logger = getLogger(__name__) + +CLIENT_SIDE_TOOL_MARKER = "uipath_client_tool" + + +def create_client_side_tool( + resource: AgentClientSideToolResourceConfig, +) -> StructuredTool: + """Create a client-side tool that pauses the graph and waits for the client to execute it. + + The tool uses @durable_interrupt to suspend the graph. The client SDK receives + an executingToolCall event, runs its registered handler, and sends endToolCall + back through CAS. The bridge routes that endToolCall to wait_for_resume(), + which unblocks the graph with the client's result. + """ + tool_name = sanitize_tool_name(resource.name) + input_model = create_model_from_schema(resource.input_schema) + + async def client_side_tool_fn( + *, tool_call_id: Annotated[str, InjectedToolCallId], **kwargs: Any + ) -> Any: + @mockable( + name=resource.name, + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=(resource.output_schema or {}), + example_calls=getattr(resource.properties, 'example_calls', None), + ) + @durable_interrupt + async def wait_for_client_execution() -> dict[str, Any]: + return { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "input": kwargs, + "is_execution_phase": True, + } + + # First run: raises GraphInterrupt with the tool call info. + # On resume: returns the client's result (output, isError, etc.) + # During evals: @mockable intercepts and returns simulated response. + result = await wait_for_client_execution() + + # The resume value from the bridge is the endToolCall payload + output = result.get("output") + is_error = result.get("is_error", False) + + content = str(output) if output is not None else "" + if isinstance(output, dict): + content = json.dumps(output) + + return ToolMessage( + content=content, + tool_call_id=tool_call_id, + status="error" if is_error else "success", + response_metadata={CLIENT_SIDE_TOOL_MARKER: True}, + ) + + # Patch signature so LangChain injects tool_call_id at runtime + original_sig = inspect.signature(client_side_tool_fn) + params = [p for p in original_sig.parameters.values() if p.name != "kwargs"] + [ + inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD, annotation=Any), + ] + client_side_tool_fn.__signature__ = original_sig.replace(parameters=params) + + tool = StructuredTool( + name=tool_name, + description=resource.description or f"Client-side tool: {tool_name}", + args_schema=input_model, + coroutine=client_side_tool_fn, + metadata={ + CLIENT_SIDE_TOOL_MARKER: True, + "output_schema": resource.output_schema, + }, + ) + + return tool diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 0cbb0135e..f6a7fb4b7 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -5,6 +5,7 @@ from langchain_core.language_models import BaseChatModel from langchain_core.tools import BaseTool from uipath.agent.models.agent import ( + AgentClientSideToolResourceConfig, AgentContextResourceConfig, AgentEscalationResourceConfig, AgentIntegrationToolResourceConfig, @@ -18,6 +19,7 @@ from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION +from .client_side_tool import create_client_side_tool from .context_tool import create_context_tool from .escalation_tool import create_escalation_tool from .extraction_tool import create_ixp_extraction_tool @@ -120,4 +122,7 @@ async def _build_tool_for_resource( elif isinstance(resource, AgentIxpVsEscalationResourceConfig): return create_ixp_escalation_tool(resource) + elif isinstance(resource, AgentClientSideToolResourceConfig): + return create_client_side_tool(resource) + return None diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 72a99800e..9c2d11670 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -126,12 +126,18 @@ def request_approval( """ tool_call_id: str = tool_args.pop("tool_call_id") + # If this is a server-side tool (not client-side), execution follows immediately + # after confirmation — mark this as the execution trigger so the bridge emits + # executingToolCall. For client-side tools, the execution interrupt sets this instead. + is_execution_trigger = not (tool.metadata or {}).get("uipath_client_tool", False) + @durable_interrupt def ask_confirmation(): return { "tool_call_id": tool_call_id, "tool_name": tool.name, "input": tool_args, + "is_execution_phase": is_execution_trigger, } response = ask_confirmation() diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index d87ed5280..34c70e260 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -24,6 +24,7 @@ UiPathConversationContentPartEndEvent, UiPathConversationContentPartEvent, UiPathConversationContentPartStartEvent, + UiPathConversationExecutingToolCallEvent, UiPathConversationMessage, UiPathConversationMessageData, UiPathConversationMessageEndEvent, @@ -64,6 +65,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None self.storage = storage self.current_message: AIMessageChunk | AIMessage self.tools_requiring_confirmation: dict[str, Any] = {} + self.client_side_tools: dict[str, Any] = {} # {tool_name: output_schema} self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() self._citation_stream_processor = CitationStreamProcessor() @@ -443,15 +445,39 @@ async def map_current_message_to_start_tool_call_events(self): tool_name in self.tools_requiring_confirmation ) input_schema = self.tools_requiring_confirmation.get(tool_name) + is_client_side = tool_name in self.client_side_tools + output_schema = ( + self.client_side_tools.get(tool_name) + if is_client_side + else None + ) 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, + is_client_side_tool=is_client_side or None, + output_schema=output_schema, ) ) + # Emit executingToolCall from MessageMapper since there's no durable interrupt + # to trigger it from the runtime loop. + if not require_confirmation and not is_client_side: + events.append( + UiPathConversationMessageEvent( + message_id=self.current_message.id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call["id"], + executing=UiPathConversationExecutingToolCallEvent( + tool_name=tool_call["name"], + input=tool_call["args"], + ), + ), + ) + ) + if self.storage is not None: await self.storage.set_value( self.runtime_id, @@ -483,19 +509,24 @@ async def map_tool_message_to_events( # Keep as string if not valid JSON pass - events = [ - UiPathConversationMessageEvent( - message_id=message_id, - tool_call=UiPathConversationToolCallEvent( - tool_call_id=message.tool_call_id, - end=UiPathConversationToolCallEndEvent( - timestamp=self.get_timestamp(), - output=content_value, - is_error=message.status == "error", + # Suppress endToolCall for client-side tools — the client already has the result (it produced it). + is_client_side = message.response_metadata.get("uipath_client_tool", False) + events: list[UiPathConversationMessageEvent] = [] + + if not is_client_side: + events.append( + UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=message.tool_call_id, + end=UiPathConversationToolCallEndEvent( + timestamp=self.get_timestamp(), + output=content_value, + is_error=message.status == "error", + ), ), - ), + ) ) - ] if is_last_tool_call: events.append(self.map_to_message_end_event(message_id)) @@ -553,6 +584,8 @@ def map_tool_call_to_tool_call_start_event( *, require_confirmation: bool | None = None, input_schema: Any | None = None, + is_client_side_tool: bool | None = None, + output_schema: Any | None = None, ) -> UiPathConversationMessageEvent: return UiPathConversationMessageEvent( message_id=message_id, @@ -564,6 +597,8 @@ def map_tool_call_to_tool_call_start_event( input=tool_call["args"], require_confirmation=require_confirmation, input_schema=input_schema, + is_client_side_tool=is_client_side_tool, + output_schema=output_schema, ), ), ) diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index da8d90918..660b126d7 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -30,6 +30,7 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema +from uipath_langchain.agent.tools.client_side_tool import CLIENT_SIDE_TOOL_MARKER 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 @@ -68,6 +69,7 @@ def __init__( self.callbacks: list[BaseCallbackHandler] = callbacks or [] self.chat = UiPathChatMessagesMapper(self.runtime_id, storage) self.chat.tools_requiring_confirmation = self._get_tool_confirmation_info() + self.chat.client_side_tools = self._get_client_side_tools() self._middleware_node_names: set[str] = self._detect_middleware_nodes() async def execute( @@ -522,6 +524,34 @@ def _get_tool_confirmation_info(self) -> dict[str, Any]: return schemas + def _get_client_side_tools(self) -> dict[str, Any]: + """Build {tool_name: output_schema} for client-side tools from compiled graph nodes.""" + + tools: dict[str, Any] = {} + 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 not None: + metadata = getattr(tool, "metadata", None) or {} + if metadata.get(CLIENT_SIDE_TOOL_MARKER): + name = getattr(tool, "name", node_name) + tools[name] = metadata.get("output_schema") + continue + + tools_by_name = getattr(bound, "tools_by_name", None) + if isinstance(tools_by_name, dict): + for name, tool in tools_by_name.items(): + metadata = getattr(tool, "metadata", None) or {} + if metadata.get(CLIENT_SIDE_TOOL_MARKER): + tools[str(getattr(tool, "name", name))] = metadata.get( + "output_schema" + ) + + return 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 From 748b83257fc4573cf4f2cc24d275285d28eef115 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 13 May 2026 21:38:03 -0400 Subject: [PATCH 02/12] feat: refactor functions and include support for simulated tools and debug --- .../agent/tools/client_side_tool.py | 62 +++----- src/uipath_langchain/agent/tools/tool_node.py | 10 +- src/uipath_langchain/chat/hitl.py | 3 +- src/uipath_langchain/runtime/messages.py | 9 +- src/uipath_langchain/runtime/runtime.py | 68 +++----- tests/runtime/test_chat_message_mapper.py | 148 ++++++++++++++++++ .../test_client_side_tool_discovery.py | 91 +++++++++++ 7 files changed, 302 insertions(+), 89 deletions(-) create mode 100644 tests/runtime/test_client_side_tool_discovery.py diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index 9178915a6..6a0b76790 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -1,8 +1,6 @@ """Factory for creating client-side tools that execute on the client SDK.""" -import inspect import json -from logging import getLogger from typing import Annotated, Any from langchain_core.messages import ToolMessage @@ -14,13 +12,10 @@ from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model as create_model_from_schema, ) +from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER from .utils import sanitize_tool_name -logger = getLogger(__name__) - -CLIENT_SIDE_TOOL_MARKER = "uipath_client_tool" - def create_client_side_tool( resource: AgentClientSideToolResourceConfig, @@ -43,44 +38,39 @@ async def client_side_tool_fn( description=resource.description, input_schema=input_model.model_json_schema(), output_schema=(resource.output_schema or {}), - example_calls=getattr(resource.properties, 'example_calls', None), + example_calls=getattr(resource.properties, "example_calls", None), ) - @durable_interrupt - async def wait_for_client_execution() -> dict[str, Any]: - return { - "tool_call_id": tool_call_id, - "tool_name": tool_name, - "input": kwargs, - "is_execution_phase": True, - } - - # First run: raises GraphInterrupt with the tool call info. - # On resume: returns the client's result (output, isError, etc.) - # During evals: @mockable intercepts and returns simulated response. - result = await wait_for_client_execution() - - # The resume value from the bridge is the endToolCall payload - output = result.get("output") - is_error = result.get("is_error", False) - - content = str(output) if output is not None else "" - if isinstance(output, dict): - content = json.dumps(output) + async def execute_tool() -> dict[str, Any]: + """Execute client-side tool, pausing for client response.""" + + @durable_interrupt + async def wait_for_client_execution() -> dict[str, Any]: + return { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "input": kwargs, + "is_execution_phase": True, + } + + result = await wait_for_client_execution() + return result.get("output", result) if isinstance(result, dict) else result + + result = await execute_tool() + + if isinstance(result, dict): + try: + content = json.dumps(result) + except TypeError: + content = str(result) + else: + content = str(result) if result is not None else "" return ToolMessage( content=content, tool_call_id=tool_call_id, - status="error" if is_error else "success", response_metadata={CLIENT_SIDE_TOOL_MARKER: True}, ) - # Patch signature so LangChain injects tool_call_id at runtime - original_sig = inspect.signature(client_side_tool_fn) - params = [p for p in original_sig.parameters.values() if p.name != "kwargs"] + [ - inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD, annotation=Any), - ] - client_side_tool_fn.__signature__ = original_sig.replace(parameters=params) - tool = StructuredTool( name=tool_name, description=resource.description or f"Client-side tool: {tool_name}", diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 88480c5a3..03028e73f 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -23,6 +23,7 @@ find_latest_ai_message, ) from uipath_langchain.chat.hitl import ( + CLIENT_SIDE_TOOL_MARKER, REQUIRE_CONVERSATIONAL_CONFIRMATION, request_conversational_tool_confirmation, ) @@ -279,10 +280,13 @@ async def _afunc(state: AgentGraphState) -> OutputType: 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) + # Preserve tool ref so the runtime can discover tool metadata + # (confirmation requirements, client-side markers, etc.) metadata = getattr(tool, "metadata", None) or {} - if isinstance(tool, BaseTool) and metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION): + if isinstance(tool, BaseTool) and ( + metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION) + or metadata.get(CLIENT_SIDE_TOOL_MARKER) + ): return RunnableCallableWithTool( func=_func, afunc=_afunc, name=tool_name, tool=tool ) diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 9c2d11670..161296867 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -14,6 +14,7 @@ CANCELLED_MESSAGE = "Cancelled by user" ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments" +CLIENT_SIDE_TOOL_MARKER = "uipath_client_tool" CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args" REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation" @@ -129,7 +130,7 @@ def request_approval( # If this is a server-side tool (not client-side), execution follows immediately # after confirmation — mark this as the execution trigger so the bridge emits # executingToolCall. For client-side tools, the execution interrupt sets this instead. - is_execution_trigger = not (tool.metadata or {}).get("uipath_client_tool", False) + is_execution_trigger = not (tool.metadata or {}).get(CLIENT_SIDE_TOOL_MARKER, False) @durable_interrupt def ask_confirmation(): diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 34c70e260..4a1c34825 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -40,6 +40,8 @@ ) from uipath.runtime import UiPathRuntimeStorageProtocol +from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER + from ._citations import ( CitationStreamProcessor, extract_citations_from_text, @@ -462,8 +464,9 @@ async def map_current_message_to_start_tool_call_events(self): ) ) - # Emit executingToolCall from MessageMapper since there's no durable interrupt - # to trigger it from the runtime loop. + # Emit executingToolCall from MessageMapper for tools without + # a durable interrupt. Tools with interrupts (client-side, HITL) + # get executingToolCall from the bridge instead. if not require_confirmation and not is_client_side: events.append( UiPathConversationMessageEvent( @@ -510,7 +513,7 @@ async def map_tool_message_to_events( pass # Suppress endToolCall for client-side tools — the client already has the result (it produced it). - is_client_side = message.response_metadata.get("uipath_client_tool", False) + is_client_side = message.response_metadata.get(CLIENT_SIDE_TOOL_MARKER, False) events: list[UiPathConversationMessageEvent] = [] if not is_client_side: diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 660b126d7..e418648ab 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -1,5 +1,6 @@ import logging import os +from collections.abc import Iterator from typing import Any, AsyncGenerator from uuid import uuid4 @@ -30,9 +31,8 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema -from uipath_langchain.agent.tools.client_side_tool import CLIENT_SIDE_TOOL_MARKER from uipath_langchain.agent.tools.tool_node import RunnableCallableWithTool -from uipath_langchain.chat.hitl import get_confirmation_schema +from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER, 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 @@ -492,64 +492,40 @@ def _detect_middleware_nodes(self) -> set[str]: return middleware_nodes - 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] = {} + def _iter_graph_tools(self) -> Iterator[BaseTool]: + """Yield all BaseTool instances from compiled graph nodes.""" 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 + tool = getattr(bound, "tool", None) + if isinstance(tool, BaseTool): + yield tool 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 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 + for t in tools_by_name.values(): + if isinstance(t, BaseTool): + yield t + def _get_tool_confirmation_info(self) -> dict[str, Any]: + """Build {tool_name: input_schema} for tools requiring confirmation.""" + schemas: dict[str, Any] = {} + for tool in self._iter_graph_tools(): + schema = get_confirmation_schema(tool) + if schema is not None: + schemas[tool.name] = schema return schemas def _get_client_side_tools(self) -> dict[str, Any]: - """Build {tool_name: output_schema} for client-side tools from compiled graph nodes.""" - + """Build {tool_name: output_schema} for client-side tools.""" tools: dict[str, Any] = {} - 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 not None: - metadata = getattr(tool, "metadata", None) or {} - if metadata.get(CLIENT_SIDE_TOOL_MARKER): - name = getattr(tool, "name", node_name) - tools[name] = metadata.get("output_schema") - continue - - tools_by_name = getattr(bound, "tools_by_name", None) - if isinstance(tools_by_name, dict): - for name, tool in tools_by_name.items(): - metadata = getattr(tool, "metadata", None) or {} - if metadata.get(CLIENT_SIDE_TOOL_MARKER): - tools[str(getattr(tool, "name", name))] = metadata.get( - "output_schema" - ) - + for tool in self._iter_graph_tools(): + metadata = getattr(tool, "metadata", None) or {} + if metadata.get(CLIENT_SIDE_TOOL_MARKER): + tools[tool.name] = metadata.get("output_schema") return tools def _is_middleware_node(self, node_name: str) -> bool: diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 6a364e68a..e089673c6 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -2199,3 +2199,151 @@ async def test_mixed_tools_only_confirmation_has_metadata(self): assert "confirm_tool" in tool_starts assert tool_starts["normal_tool"].require_confirmation is None assert tool_starts["confirm_tool"].require_confirmation is True + + +class TestExecutingToolCallEmission: + """Tests for executingToolCall event emission from MessageMapper.""" + + @pytest.mark.asyncio + async def test_emits_executing_for_normal_tool(self): + """Should emit executingToolCall for a server tool without confirmation or client-side marker.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + first_chunk = AIMessageChunk( + content="", + id="msg-1", + tool_calls=[{"id": "tc-1", "name": "server_tool", "args": {"x": 1}}], + ) + await mapper.map_event(first_chunk) + + 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 + executing_events = [ + e for e in result + if e.tool_call is not None and e.tool_call.executing is not None + ] + assert len(executing_events) == 1 + assert executing_events[0].tool_call.executing.tool_name == "server_tool" + + @pytest.mark.asyncio + async def test_no_executing_for_confirmation_tool(self): + """Should NOT emit executingToolCall for a tool that requires confirmation.""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.tools_requiring_confirmation = {"confirm_tool": {}} + + first_chunk = AIMessageChunk( + content="", + id="msg-1", + tool_calls=[{"id": "tc-1", "name": "confirm_tool", "args": {}}], + ) + await mapper.map_event(first_chunk) + + 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 + executing_events = [ + e for e in result + if e.tool_call is not None and e.tool_call.executing is not None + ] + assert len(executing_events) == 0 + + @pytest.mark.asyncio + async def test_no_executing_for_client_side_tool(self): + """Should NOT emit executingToolCall for a client-side tool (bridge handles it).""" + storage = create_mock_storage() + storage.get_value.return_value = {} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + mapper.client_side_tools = {"client_tool": {"type": "object"}} + + first_chunk = AIMessageChunk( + content="", + id="msg-1", + tool_calls=[{"id": "tc-1", "name": "client_tool", "args": {"title": "Avatar"}}], + ) + await mapper.map_event(first_chunk) + + 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 + executing_events = [ + e for e in result + if e.tool_call is not None and e.tool_call.executing is not None + ] + assert len(executing_events) == 0 + + +class TestClientSideToolEndSuppression: + """Tests for suppressing endToolCall for client-side tools.""" + + @pytest.mark.asyncio + async def test_client_side_tool_suppresses_end_event(self): + """ToolMessage with CLIENT_SIDE_TOOL_MARKER should NOT emit endToolCall.""" + storage = create_mock_storage() + storage.get_value.return_value = {"tool-1": "msg-123"} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + tool_msg = ToolMessage( + content='{"rating": 9}', + tool_call_id="tool-1", + response_metadata={"uipath_client_tool": True}, + ) + + result = await mapper.map_event(tool_msg) + + assert result is not None + tool_end_events = [ + e for e in result + if e.tool_call is not None and e.tool_call.end is not None + ] + assert len(tool_end_events) == 0 + + @pytest.mark.asyncio + async def test_client_side_tool_still_emits_message_end(self): + """ToolMessage with CLIENT_SIDE_TOOL_MARKER should still emit message end when it's the last tool.""" + storage = create_mock_storage() + storage.get_value.return_value = {"tool-1": "msg-123"} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + tool_msg = ToolMessage( + content='{"rating": 9}', + tool_call_id="tool-1", + response_metadata={"uipath_client_tool": True}, + ) + + result = await mapper.map_event(tool_msg) + + assert result is not None + message_end_events = [e for e in result if e.end is not None] + assert len(message_end_events) == 1 + + @pytest.mark.asyncio + async def test_normal_tool_emits_end_event(self): + """ToolMessage without CLIENT_SIDE_TOOL_MARKER should emit endToolCall normally.""" + storage = create_mock_storage() + storage.get_value.return_value = {"tool-1": "msg-123"} + mapper = UiPathChatMessagesMapper("test-runtime", storage) + + tool_msg = ToolMessage( + content='{"result": "success"}', + tool_call_id="tool-1", + ) + + result = await mapper.map_event(tool_msg) + + assert result is not None + tool_end_events = [ + e for e in result + if e.tool_call is not None and e.tool_call.end is not None + ] + assert len(tool_end_events) == 1 diff --git a/tests/runtime/test_client_side_tool_discovery.py b/tests/runtime/test_client_side_tool_discovery.py new file mode 100644 index 000000000..d74922942 --- /dev/null +++ b/tests/runtime/test_client_side_tool_discovery.py @@ -0,0 +1,91 @@ +"""Tests that _get_client_side_tools discovers client-side tools through RunnableCallableWithTool wrappers. + +Integration guard: if the wrapping pipeline changes and stops preserving the +BaseTool reference for client-side tools, 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 CLIENT_SIDE_TOOL_MARKER +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + + +class _ClientSideInput(BaseModel): + title: str = Field(description="Movie title") + + +class _ClientSideTool(BaseTool): + name: str = "client_tool" + description: str = "A client-side tool" + args_schema: type[BaseModel] = _ClientSideInput + metadata: dict[str, Any] = { + CLIENT_SIDE_TOOL_MARKER: True, + "output_schema": {"type": "object", "properties": {"rating": {"type": "number"}}}, + } + + def _run(self, title: str) -> str: + return f"result for {title}" + + +class _NormalTool(BaseTool): + name: str = "normal_tool" + description: str = "A normal server 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) + + 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 TestClientSideToolDiscovery: + def test_discovers_client_side_tool_through_wrapper(self): + graph = _compile_graph_with_wrapped_tools([_ClientSideTool(), _NormalTool()]) + runtime = UiPathLangGraphRuntime(graph) + + client_tools = runtime.chat.client_side_tools + assert "client_tool" in client_tools + assert "normal_tool" not in client_tools + + def test_output_schema_is_preserved(self): + graph = _compile_graph_with_wrapped_tools([_ClientSideTool()]) + runtime = UiPathLangGraphRuntime(graph) + + schema = runtime.chat.client_side_tools["client_tool"] + assert schema is not None + assert "properties" in schema + assert "rating" in schema["properties"] + + def test_empty_when_no_client_side_tools(self): + graph = _compile_graph_with_wrapped_tools([_NormalTool()]) + runtime = UiPathLangGraphRuntime(graph) + + assert runtime.chat.client_side_tools == {} From 3e7f29cfb2357a438d0885ffc7939afe27988822 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 15 May 2026 16:34:30 -0400 Subject: [PATCH 03/12] chore: add client side tool validation and tool passing here --- src/uipath_langchain/agent/react/agent.py | 24 +++++- src/uipath_langchain/agent/react/init_node.py | 16 ++++ .../agent/tools/client_side_tool.py | 83 ++++++++++++++++++- src/uipath_langchain/agent/tools/tool_node.py | 4 +- src/uipath_langchain/chat/hitl.py | 6 +- src/uipath_langchain/runtime/messages.py | 15 +++- src/uipath_langchain/runtime/runtime.py | 22 +++-- tests/runtime/test_chat_message_mapper.py | 29 ++++--- .../test_client_side_tool_discovery.py | 21 +++-- 9 files changed, 180 insertions(+), 40 deletions(-) diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 391bd74a8..189eb9ce6 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -1,4 +1,4 @@ -from typing import Callable, Sequence, Type, TypeVar +from typing import Any, Callable, Sequence, Type, TypeVar from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage, SystemMessage @@ -9,6 +9,9 @@ from uipath.platform.context_grounding import DeepRagContent from uipath.platform.guardrails import BaseGuardrail +from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo +from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL + from ...runtime._citations import cas_deep_rag_citation_wrapper from ..guardrails.actions import GuardrailAction from ..tools.structured_tool_with_output_type import StructuredToolWithOutputType @@ -77,7 +80,24 @@ def create_agent( ) llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools] - init_node = create_init_node(messages, input_schema, config.is_conversational) + # Derive client-side tool schemas from tools for input validation in the init node. + cs_tools: dict[str, ClientSideToolInfo] | None = None + if config.is_conversational: + cs_tools = {} + for t in agent_tools: + meta = getattr(t, "metadata", None) or {} + if meta.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL): + cs_tools[t.name] = { + "input_schema": t.args_schema.model_json_schema() + if hasattr(t, "args_schema") and t.args_schema + else None, + "output_schema": meta.get("output_schema"), + } + cs_tools = cs_tools or None + + init_node = create_init_node( + messages, input_schema, config.is_conversational, cs_tools + ) tool_nodes = create_tool_node(agent_tools) diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index 2b8a9c77b..139b6a155 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -6,6 +6,13 @@ from langgraph.types import Overwrite from pydantic import BaseModel +from uipath_langchain.agent.tools.client_side_tool import ( + UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, + ClientSideToolInfo, + available_client_side_tools, + validate_and_apply_tool_filter, +) + from .job_attachments import ( get_job_attachments, parse_attachments_from_conversation_messages, @@ -17,6 +24,7 @@ def create_init_node( | Callable[[Any], Sequence[SystemMessage | HumanMessage]], input_schema: type[BaseModel] | None, is_conversational: bool = False, + client_side_tools: dict[str, ClientSideToolInfo] | None = None, ): def graph_state_init(state: Any) -> Any: resolved_messages: Sequence[SystemMessage | HumanMessage] | Overwrite @@ -63,6 +71,14 @@ def graph_state_init(state: Any) -> Any: ) job_attachments_dict.update(message_attachments) + # Validate client-side tool declarations from the exchange input + if client_side_tools: + client_tools_input = getattr(state, UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, None) + if client_tools_input is not None and isinstance(client_tools_input, list): + validate_and_apply_tool_filter(client_tools_input, client_side_tools) + else: + available_client_side_tools.set(None) + # Calculate initial message count for tracking new messages initial_message_count = ( len(resolved_messages.value) diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index 6a0b76790..1c839b123 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -1,7 +1,8 @@ """Factory for creating client-side tools that execute on the client SDK.""" import json -from typing import Annotated, Any +from contextvars import ContextVar +from typing import Annotated, Any, TypedDict from langchain_core.messages import ToolMessage from langchain_core.tools import InjectedToolCallId, StructuredTool @@ -12,10 +13,76 @@ from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model as create_model_from_schema, ) -from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER +from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from .utils import sanitize_tool_name +# When set, only tools in this set are available for the current exchange. +# None means all client-side tools are available (default for CAS/web UI). +available_client_side_tools: ContextVar[set[str] | None] = ContextVar( + "available_client_side_tools", default=None +) + +UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY = "uipath__client_side_tools" + + +class ClientSideToolInfo(TypedDict): + input_schema: dict[str, Any] | None + output_schema: dict[str, Any] | None + + +def validate_and_apply_tool_filter( + declared_tools: list[dict[str, Any]], + agent_tools: dict[str, ClientSideToolInfo], +) -> None: + """Validate client-side tool declarations and set the availability filter. + + Compares the client's declared tools against the agent's tool definitions. + Raises ValueError if required tools are missing or schemas don't match. + Sets the available_client_side_tools context variable for tool functions. + + Args: + declared_tools: List of tool declarations from uipath__client_side_tools input. + Each item is a dict with 'name' and optional 'inputSchema'/'outputSchema'. + agent_tools: The agent's client-side tools. + Dict of {tool_name: ClientSideToolInfo}. + """ + declared = { + (t["name"] if isinstance(t, dict) else t): t + if isinstance(t, dict) + else {"name": t} + for t in declared_tools + } + + required = set(agent_tools.keys()) + missing = required - set(declared.keys()) + if missing: + raise ValueError( + f"Missing required client-side tools: {', '.join(sorted(missing))}. " + f"The client must register handlers for all client-side tools defined by the agent." + ) + + for name, decl in declared.items(): + agent_tool = agent_tools.get(name) + if agent_tool is None: + continue # Unknown tool, runtime will ignore it + if decl.get("inputSchema") and agent_tool.get("input_schema"): + if json.dumps(decl["inputSchema"], sort_keys=True) != json.dumps( + agent_tool["input_schema"], sort_keys=True + ): + raise ValueError( + f"Client-side tool '{name}' inputSchema does not match agent definition." + ) + if decl.get("outputSchema") and agent_tool.get("output_schema"): + if json.dumps(decl["outputSchema"], sort_keys=True) != json.dumps( + agent_tool["output_schema"], sort_keys=True + ): + raise ValueError( + f"Client-side tool '{name}' outputSchema does not match agent definition." + ) + + available_client_side_tools.set(set(declared.keys())) + def create_client_side_tool( resource: AgentClientSideToolResourceConfig, @@ -33,6 +100,14 @@ def create_client_side_tool( async def client_side_tool_fn( *, tool_call_id: Annotated[str, InjectedToolCallId], **kwargs: Any ) -> Any: + allowed = available_client_side_tools.get() + if allowed is not None and tool_name not in allowed: + return ToolMessage( + content=f"Tool '{tool_name}' is not available — the client has not registered a handler for it.", + tool_call_id=tool_call_id, + status="error", + ) + @mockable( name=resource.name, description=resource.description, @@ -68,7 +143,7 @@ async def wait_for_client_execution() -> dict[str, Any]: return ToolMessage( content=content, tool_call_id=tool_call_id, - response_metadata={CLIENT_SIDE_TOOL_MARKER: True}, + response_metadata={IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True}, ) tool = StructuredTool( @@ -77,7 +152,7 @@ async def wait_for_client_execution() -> dict[str, Any]: args_schema=input_model, coroutine=client_side_tool_fn, metadata={ - CLIENT_SIDE_TOOL_MARKER: True, + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True, "output_schema": resource.output_schema, }, ) diff --git a/src/uipath_langchain/agent/tools/tool_node.py b/src/uipath_langchain/agent/tools/tool_node.py index 03028e73f..f28a3b28f 100644 --- a/src/uipath_langchain/agent/tools/tool_node.py +++ b/src/uipath_langchain/agent/tools/tool_node.py @@ -23,7 +23,7 @@ find_latest_ai_message, ) from uipath_langchain.chat.hitl import ( - CLIENT_SIDE_TOOL_MARKER, + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, REQUIRE_CONVERSATIONAL_CONFIRMATION, request_conversational_tool_confirmation, ) @@ -285,7 +285,7 @@ async def _afunc(state: AgentGraphState) -> OutputType: metadata = getattr(tool, "metadata", None) or {} if isinstance(tool, BaseTool) and ( metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION) - or metadata.get(CLIENT_SIDE_TOOL_MARKER) + or metadata.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL) ): return RunnableCallableWithTool( func=_func, afunc=_afunc, name=tool_name, tool=tool diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 161296867..0e1ecf348 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -14,7 +14,7 @@ CANCELLED_MESSAGE = "Cancelled by user" ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments" -CLIENT_SIDE_TOOL_MARKER = "uipath_client_tool" +IS_CONVERSATIONAL_CLIENT_SIDE_TOOL = "uipath_client_tool" CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args" REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation" @@ -130,7 +130,9 @@ def request_approval( # If this is a server-side tool (not client-side), execution follows immediately # after confirmation — mark this as the execution trigger so the bridge emits # executingToolCall. For client-side tools, the execution interrupt sets this instead. - is_execution_trigger = not (tool.metadata or {}).get(CLIENT_SIDE_TOOL_MARKER, False) + is_execution_trigger = not (tool.metadata or {}).get( + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, False + ) @durable_interrupt def ask_confirmation(): diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 4a1c34825..509381253 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -40,7 +40,7 @@ ) from uipath.runtime import UiPathRuntimeStorageProtocol -from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER +from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from ._citations import ( CitationStreamProcessor, @@ -67,7 +67,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None self.storage = storage self.current_message: AIMessageChunk | AIMessage self.tools_requiring_confirmation: dict[str, Any] = {} - self.client_side_tools: dict[str, Any] = {} # {tool_name: output_schema} + self.client_side_tools: dict[str, Any] = {} self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() self._citation_stream_processor = CitationStreamProcessor() @@ -448,8 +448,13 @@ async def map_current_message_to_start_tool_call_events(self): ) input_schema = self.tools_requiring_confirmation.get(tool_name) is_client_side = tool_name in self.client_side_tools + client_tool_info = self.client_side_tools.get(tool_name) output_schema = ( - self.client_side_tools.get(tool_name) + ( + client_tool_info.get("output_schema") + if isinstance(client_tool_info, dict) + else client_tool_info + ) if is_client_side else None ) @@ -513,7 +518,9 @@ async def map_tool_message_to_events( pass # Suppress endToolCall for client-side tools — the client already has the result (it produced it). - is_client_side = message.response_metadata.get(CLIENT_SIDE_TOOL_MARKER, False) + is_client_side = message.response_metadata.get( + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, False + ) events: list[UiPathConversationMessageEvent] = [] if not is_client_side: diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index e418648ab..2baef462e 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -31,8 +31,12 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema +from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo from uipath_langchain.agent.tools.tool_node import RunnableCallableWithTool -from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER, get_confirmation_schema +from uipath_langchain.chat.hitl import ( + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, + 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 @@ -519,13 +523,19 @@ def _get_tool_confirmation_info(self) -> dict[str, Any]: schemas[tool.name] = schema return schemas - def _get_client_side_tools(self) -> dict[str, Any]: - """Build {tool_name: output_schema} for client-side tools.""" - tools: dict[str, Any] = {} + def _get_client_side_tools(self) -> dict[str, ClientSideToolInfo]: + """Build {tool_name: ClientSideToolInfo} for client-side tools.""" + tools: dict[str, ClientSideToolInfo] = {} for tool in self._iter_graph_tools(): metadata = getattr(tool, "metadata", None) or {} - if metadata.get(CLIENT_SIDE_TOOL_MARKER): - tools[tool.name] = metadata.get("output_schema") + if metadata.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL): + input_schema = None + if hasattr(tool, "args_schema") and tool.args_schema: + input_schema = tool.args_schema.model_json_schema() + tools[tool.name] = { + "input_schema": input_schema, + "output_schema": metadata.get("output_schema"), + } return tools def _is_middleware_node(self, node_name: str) -> bool: diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index e089673c6..79b1a2c2d 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -2224,7 +2224,8 @@ async def test_emits_executing_for_normal_tool(self): assert result is not None executing_events = [ - e for e in result + e + for e in result if e.tool_call is not None and e.tool_call.executing is not None ] assert len(executing_events) == 1 @@ -2251,7 +2252,8 @@ async def test_no_executing_for_confirmation_tool(self): assert result is not None executing_events = [ - e for e in result + e + for e in result if e.tool_call is not None and e.tool_call.executing is not None ] assert len(executing_events) == 0 @@ -2262,12 +2264,16 @@ async def test_no_executing_for_client_side_tool(self): storage = create_mock_storage() storage.get_value.return_value = {} mapper = UiPathChatMessagesMapper("test-runtime", storage) - mapper.client_side_tools = {"client_tool": {"type": "object"}} + mapper.client_side_tools = { + "client_tool": {"input_schema": None, "output_schema": {"type": "object"}} + } first_chunk = AIMessageChunk( content="", id="msg-1", - tool_calls=[{"id": "tc-1", "name": "client_tool", "args": {"title": "Avatar"}}], + tool_calls=[ + {"id": "tc-1", "name": "client_tool", "args": {"title": "Avatar"}} + ], ) await mapper.map_event(first_chunk) @@ -2277,7 +2283,8 @@ async def test_no_executing_for_client_side_tool(self): assert result is not None executing_events = [ - e for e in result + e + for e in result if e.tool_call is not None and e.tool_call.executing is not None ] assert len(executing_events) == 0 @@ -2288,7 +2295,7 @@ class TestClientSideToolEndSuppression: @pytest.mark.asyncio async def test_client_side_tool_suppresses_end_event(self): - """ToolMessage with CLIENT_SIDE_TOOL_MARKER should NOT emit endToolCall.""" + """ToolMessage with IS_CONVERSATIONAL_CLIENT_SIDE_TOOL should NOT emit endToolCall.""" storage = create_mock_storage() storage.get_value.return_value = {"tool-1": "msg-123"} mapper = UiPathChatMessagesMapper("test-runtime", storage) @@ -2303,14 +2310,13 @@ async def test_client_side_tool_suppresses_end_event(self): assert result is not None tool_end_events = [ - e for e in result - if e.tool_call is not None and e.tool_call.end is not None + e for e in result if e.tool_call is not None and e.tool_call.end is not None ] assert len(tool_end_events) == 0 @pytest.mark.asyncio async def test_client_side_tool_still_emits_message_end(self): - """ToolMessage with CLIENT_SIDE_TOOL_MARKER should still emit message end when it's the last tool.""" + """ToolMessage with IS_CONVERSATIONAL_CLIENT_SIDE_TOOL should still emit message end when it's the last tool.""" storage = create_mock_storage() storage.get_value.return_value = {"tool-1": "msg-123"} mapper = UiPathChatMessagesMapper("test-runtime", storage) @@ -2329,7 +2335,7 @@ async def test_client_side_tool_still_emits_message_end(self): @pytest.mark.asyncio async def test_normal_tool_emits_end_event(self): - """ToolMessage without CLIENT_SIDE_TOOL_MARKER should emit endToolCall normally.""" + """ToolMessage without IS_CONVERSATIONAL_CLIENT_SIDE_TOOL should emit endToolCall normally.""" storage = create_mock_storage() storage.get_value.return_value = {"tool-1": "msg-123"} mapper = UiPathChatMessagesMapper("test-runtime", storage) @@ -2343,7 +2349,6 @@ async def test_normal_tool_emits_end_event(self): assert result is not None tool_end_events = [ - e for e in result - if e.tool_call is not None and e.tool_call.end is not None + e for e in result if e.tool_call is not None and e.tool_call.end is not None ] assert len(tool_end_events) == 1 diff --git a/tests/runtime/test_client_side_tool_discovery.py b/tests/runtime/test_client_side_tool_discovery.py index d74922942..1fa3008d0 100644 --- a/tests/runtime/test_client_side_tool_discovery.py +++ b/tests/runtime/test_client_side_tool_discovery.py @@ -15,7 +15,7 @@ UiPathToolNode, wrap_tools_with_error_handling, ) -from uipath_langchain.chat.hitl import CLIENT_SIDE_TOOL_MARKER +from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime @@ -28,8 +28,11 @@ class _ClientSideTool(BaseTool): description: str = "A client-side tool" args_schema: type[BaseModel] = _ClientSideInput metadata: dict[str, Any] = { - CLIENT_SIDE_TOOL_MARKER: True, - "output_schema": {"type": "object", "properties": {"rating": {"type": "number"}}}, + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True, + "output_schema": { + "type": "object", + "properties": {"rating": {"type": "number"}}, + }, } def _run(self, title: str) -> str: @@ -75,14 +78,16 @@ def test_discovers_client_side_tool_through_wrapper(self): assert "client_tool" in client_tools assert "normal_tool" not in client_tools - def test_output_schema_is_preserved(self): + def test_schemas_are_preserved(self): graph = _compile_graph_with_wrapped_tools([_ClientSideTool()]) runtime = UiPathLangGraphRuntime(graph) - schema = runtime.chat.client_side_tools["client_tool"] - assert schema is not None - assert "properties" in schema - assert "rating" in schema["properties"] + tool_info = runtime.chat.client_side_tools["client_tool"] + assert tool_info is not None + assert "output_schema" in tool_info + assert "input_schema" in tool_info + assert "rating" in tool_info["output_schema"]["properties"] + assert "title" in tool_info["input_schema"]["properties"] def test_empty_when_no_client_side_tools(self): graph = _compile_graph_with_wrapped_tools([_NormalTool()]) From 1d108d2ffccea5d7f822bd7f5474cf917073ab65 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Mon, 18 May 2026 10:25:18 -0400 Subject: [PATCH 04/12] chore: dont emit execute tool call for confirmation --- src/uipath_langchain/chat/hitl.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 0e1ecf348..198f65d35 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -127,20 +127,13 @@ def request_approval( """ tool_call_id: str = tool_args.pop("tool_call_id") - # If this is a server-side tool (not client-side), execution follows immediately - # after confirmation — mark this as the execution trigger so the bridge emits - # executingToolCall. For client-side tools, the execution interrupt sets this instead. - is_execution_trigger = not (tool.metadata or {}).get( - IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, False - ) - @durable_interrupt def ask_confirmation(): return { "tool_call_id": tool_call_id, "tool_name": tool.name, "input": tool_args, - "is_execution_phase": is_execution_trigger, + "is_execution_phase": False, } response = ask_confirmation() From 0f6e711cd9e4aa3733fedf5c6a2947034ff87e6c Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 19 May 2026 10:20:48 -0400 Subject: [PATCH 05/12] chore: extra validation --- .../agent/tools/client_side_tool.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index 1c839b123..a56d4bcc5 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -47,12 +47,26 @@ def validate_and_apply_tool_filter( agent_tools: The agent's client-side tools. Dict of {tool_name: ClientSideToolInfo}. """ - declared = { - (t["name"] if isinstance(t, dict) else t): t - if isinstance(t, dict) - else {"name": t} - for t in declared_tools - } + declared: dict[str, dict[str, Any]] = {} + for i, t in enumerate(declared_tools): + if isinstance(t, dict): + if "name" not in t: + raise ValueError( + f"Client-side tool declaration at index {i} is missing required 'name' field." + ) + name = t["name"] + elif isinstance(t, str): + name = t + t = {"name": t} + else: + raise ValueError( + f"Client-side tool declaration at index {i} must be a dict or string, got {type(t).__name__}." + ) + if name in declared: + raise ValueError( + f"Duplicate client-side tool declaration: '{name}'." + ) + declared[name] = t required = set(agent_tools.keys()) missing = required - set(declared.keys()) From 050977d718c6b2bd2c92cfeaa0c26a8cfb9cdcbe Mon Sep 17 00:00:00 2001 From: Norman Le Date: Tue, 19 May 2026 13:01:33 -0400 Subject: [PATCH 06/12] test: validation and tests --- src/uipath_langchain/agent/react/agent.py | 2 +- src/uipath_langchain/agent/react/init_node.py | 11 +- src/uipath_langchain/runtime/messages.py | 10 +- .../tools/test_client_side_tool_validation.py | 273 ++++++++++++++++++ 4 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 tests/agent/tools/test_client_side_tool_validation.py diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 189eb9ce6..5f6511779 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Sequence, Type, TypeVar +from typing import Callable, Sequence, Type, TypeVar from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage, SystemMessage diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index 139b6a155..4a21b06ae 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -74,10 +74,15 @@ def graph_state_init(state: Any) -> Any: # Validate client-side tool declarations from the exchange input if client_side_tools: client_tools_input = getattr(state, UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, None) - if client_tools_input is not None and isinstance(client_tools_input, list): - validate_and_apply_tool_filter(client_tools_input, client_side_tools) - else: + if client_tools_input is None: available_client_side_tools.set(None) + elif not isinstance(client_tools_input, list): + raise ValueError( + f"'{UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY}' must be a list of tool declarations, " + f"got {type(client_tools_input).__name__}." + ) + else: + validate_and_apply_tool_filter(client_tools_input, client_side_tools) # Calculate initial message count for tracking new messages initial_message_count = ( diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 509381253..60c4bb53b 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -40,6 +40,7 @@ ) from uipath.runtime import UiPathRuntimeStorageProtocol +from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL from ._citations import ( @@ -67,7 +68,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None self.storage = storage self.current_message: AIMessageChunk | AIMessage self.tools_requiring_confirmation: dict[str, Any] = {} - self.client_side_tools: dict[str, Any] = {} + self.client_side_tools: dict[str, ClientSideToolInfo] = {} self.seen_message_ids: set[str] = set() self._storage_lock = asyncio.Lock() self._citation_stream_processor = CitationStreamProcessor() @@ -448,13 +449,8 @@ async def map_current_message_to_start_tool_call_events(self): ) input_schema = self.tools_requiring_confirmation.get(tool_name) is_client_side = tool_name in self.client_side_tools - client_tool_info = self.client_side_tools.get(tool_name) output_schema = ( - ( - client_tool_info.get("output_schema") - if isinstance(client_tool_info, dict) - else client_tool_info - ) + self.client_side_tools[tool_name].get("output_schema") if is_client_side else None ) diff --git a/tests/agent/tools/test_client_side_tool_validation.py b/tests/agent/tools/test_client_side_tool_validation.py new file mode 100644 index 000000000..96fed835a --- /dev/null +++ b/tests/agent/tools/test_client_side_tool_validation.py @@ -0,0 +1,273 @@ +"""Tests for client-side tool validation and filtering logic.""" + +import pytest + +from uipath_langchain.agent.tools.client_side_tool import ( + ClientSideToolInfo, + available_client_side_tools, + validate_and_apply_tool_filter, +) + +AGENT_TOOLS: dict[str, ClientSideToolInfo] = { + "get_weather": { + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + "output_schema": { + "type": "object", + "properties": {"temp": {"type": "number"}}, + }, + }, + "show_map": { + "input_schema": None, + "output_schema": None, + }, +} + + +class TestValidateAndApplyToolFilter: + """Tests for validate_and_apply_tool_filter.""" + + def test_valid_declarations_set_filter(self): + declared = [ + {"name": "get_weather"}, + {"name": "show_map"}, + ] + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + allowed = available_client_side_tools.get() + assert allowed == {"get_weather", "show_map"} + + def test_missing_required_tool_raises(self): + declared = [{"name": "get_weather"}] # missing show_map + + with pytest.raises(ValueError, match="Missing required client-side tools"): + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + def test_input_schema_mismatch_raises(self): + declared = [ + { + "name": "get_weather", + "inputSchema": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, + }, + {"name": "show_map"}, + ] + + with pytest.raises(ValueError, match="inputSchema does not match"): + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + def test_output_schema_mismatch_raises(self): + declared = [ + { + "name": "get_weather", + "outputSchema": { + "type": "object", + "properties": {"temperature": {"type": "string"}}, + }, + }, + {"name": "show_map"}, + ] + + with pytest.raises(ValueError, match="outputSchema does not match"): + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + def test_unknown_extra_tools_are_ignored(self): + declared = [ + {"name": "get_weather"}, + {"name": "show_map"}, + {"name": "unknown_tool"}, + ] + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + allowed = available_client_side_tools.get() + assert allowed is not None + assert "unknown_tool" in allowed + assert "get_weather" in allowed + + def test_string_declarations_accepted(self): + declared = ["get_weather", "show_map"] + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + allowed = available_client_side_tools.get() + assert allowed == {"get_weather", "show_map"} + + def test_missing_name_field_raises(self): + declared = [{"inputSchema": {}}] + + with pytest.raises(ValueError, match="missing required 'name' field"): + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + def test_invalid_type_raises(self): + declared = [123] + + with pytest.raises(ValueError, match="must be a dict or string"): + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + def test_duplicate_name_raises(self): + declared = [ + {"name": "get_weather"}, + {"name": "get_weather"}, + {"name": "show_map"}, + ] + + with pytest.raises(ValueError, match="Duplicate client-side tool"): + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + def test_matching_schemas_pass(self): + declared = [ + { + "name": "get_weather", + "inputSchema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + "outputSchema": { + "type": "object", + "properties": {"temp": {"type": "number"}}, + }, + }, + {"name": "show_map"}, + ] + validate_and_apply_tool_filter(declared, AGENT_TOOLS) + + allowed = available_client_side_tools.get() + assert allowed is not None + assert "get_weather" in allowed + + +class TestToolNotAvailableEnforcement: + """Tests that client_side_tool_fn returns error ToolMessage when tool is filtered out.""" + + def test_tool_not_in_allowed_set_returns_error(self): + token = available_client_side_tools.set({"other_tool"}) + try: + from unittest.mock import AsyncMock, patch + + from uipath.agent.models.agent import AgentClientSideToolResourceConfig + + resource = AgentClientSideToolResourceConfig( + name="my_tool", + description="A test tool", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + output_schema=None, + ) + + from uipath_langchain.agent.tools.client_side_tool import ( + create_client_side_tool, + ) + + tool = create_client_side_tool(resource) + + import asyncio + + result = asyncio.get_event_loop().run_until_complete( + tool.coroutine(tool_call_id="tc-1", query="test") + ) + + assert result.status == "error" + assert "not available" in result.content + finally: + available_client_side_tools.reset(token) + + def test_tool_in_allowed_set_proceeds(self): + """When tool IS in the allowed set, it should NOT return an error. + + We can't fully test execution (it would hit durable_interrupt), + but we verify the availability check passes by patching the interrupt. + """ + token = available_client_side_tools.set({"my_tool"}) + try: + from unittest.mock import AsyncMock, patch + + from uipath.agent.models.agent import AgentClientSideToolResourceConfig + + resource = AgentClientSideToolResourceConfig( + name="my_tool", + description="A test tool", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + output_schema=None, + ) + + from uipath_langchain.agent.tools.client_side_tool import ( + create_client_side_tool, + ) + + tool = create_client_side_tool(resource) + + import asyncio + + # Patch durable_interrupt to avoid GraphInterrupt + with ( + patch( + "uipath_langchain.agent.tools.client_side_tool.durable_interrupt", + side_effect=lambda fn: fn, + ), + patch( + "uipath_langchain.agent.tools.client_side_tool.mockable", + side_effect=lambda **kw: lambda fn: fn, + ), + ): + # Re-create tool after patching + tool = create_client_side_tool(resource) + result = asyncio.get_event_loop().run_until_complete( + tool.coroutine(tool_call_id="tc-1", query="test") + ) + # Should NOT be an error ToolMessage — it proceeded past the availability check + if hasattr(result, "status"): + assert result.status != "error" + finally: + available_client_side_tools.reset(token) + + def test_none_allowed_set_permits_all(self): + """When available_client_side_tools is None (CAS default), all tools proceed.""" + token = available_client_side_tools.set(None) + try: + from uipath.agent.models.agent import AgentClientSideToolResourceConfig + + resource = AgentClientSideToolResourceConfig( + name="any_tool", + description="A test tool", + input_schema={ + "type": "object", + "properties": {"q": {"type": "string"}}, + }, + output_schema=None, + ) + + from unittest.mock import patch + + from uipath_langchain.agent.tools.client_side_tool import ( + create_client_side_tool, + ) + + with ( + patch( + "uipath_langchain.agent.tools.client_side_tool.durable_interrupt", + side_effect=lambda fn: fn, + ), + patch( + "uipath_langchain.agent.tools.client_side_tool.mockable", + side_effect=lambda **kw: lambda fn: fn, + ), + ): + tool = create_client_side_tool(resource) + + import asyncio + + result = asyncio.get_event_loop().run_until_complete( + tool.coroutine(tool_call_id="tc-1", q="test") + ) + if hasattr(result, "status"): + assert result.status != "error" + finally: + available_client_side_tools.reset(token) From f1ebd89e880e38392f9445bfd17e81ec01060b8b Mon Sep 17 00:00:00 2001 From: Norman Le Date: Wed, 27 May 2026 15:43:52 -0400 Subject: [PATCH 07/12] feat: update validation and remove tool name input from event --- src/uipath_langchain/agent/react/init_node.py | 26 +-- .../agent/tools/client_side_tool.py | 76 ++----- src/uipath_langchain/chat/hitl.py | 10 +- src/uipath_langchain/runtime/messages.py | 1 - .../tools/test_client_side_tool_validation.py | 202 ++++++------------ tests/runtime/test_chat_message_mapper.py | 2 +- 6 files changed, 105 insertions(+), 212 deletions(-) diff --git a/src/uipath_langchain/agent/react/init_node.py b/src/uipath_langchain/agent/react/init_node.py index 4a21b06ae..7b9f5b06a 100644 --- a/src/uipath_langchain/agent/react/init_node.py +++ b/src/uipath_langchain/agent/react/init_node.py @@ -9,8 +9,8 @@ from uipath_langchain.agent.tools.client_side_tool import ( UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, ClientSideToolInfo, + apply_tool_filter, available_client_side_tools, - validate_and_apply_tool_filter, ) from .job_attachments import ( @@ -71,18 +71,20 @@ def graph_state_init(state: Any) -> Any: ) job_attachments_dict.update(message_attachments) - # Validate client-side tool declarations from the exchange input - if client_side_tools: - client_tools_input = getattr(state, UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, None) - if client_tools_input is None: - available_client_side_tools.set(None) - elif not isinstance(client_tools_input, list): - raise ValueError( - f"'{UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY}' must be a list of tool declarations, " - f"got {type(client_tools_input).__name__}." + # Filter available client-side tools based on exchange input declarations + if client_side_tools: + client_tools_input = getattr( + state, UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, None ) - else: - validate_and_apply_tool_filter(client_tools_input, client_side_tools) + if client_tools_input is None: + available_client_side_tools.set(None) + elif not isinstance(client_tools_input, list): + raise ValueError( + f"'{UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY}' must be a list of tool names, " + f"got {type(client_tools_input).__name__}." + ) + else: + apply_tool_filter(client_tools_input, client_side_tools) # Calculate initial message count for tracking new messages initial_message_count = ( diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index a56d4bcc5..cfe535297 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -31,71 +31,29 @@ class ClientSideToolInfo(TypedDict): output_schema: dict[str, Any] | None -def validate_and_apply_tool_filter( - declared_tools: list[dict[str, Any]], +def apply_tool_filter( + declared_tools: list[str | dict[str, Any]], agent_tools: dict[str, ClientSideToolInfo], ) -> None: - """Validate client-side tool declarations and set the availability filter. + """Filter available client-side tools to the intersection of declared and agent tools. - Compares the client's declared tools against the agent's tool definitions. - Raises ValueError if required tools are missing or schemas don't match. - Sets the available_client_side_tools context variable for tool functions. + Extracts tool names from the client's declarations, intersects with the agent's + defined client-side tools, and sets the availability filter. Unknown names are + silently ignored. Args: - declared_tools: List of tool declarations from uipath__client_side_tools input. - Each item is a dict with 'name' and optional 'inputSchema'/'outputSchema'. - agent_tools: The agent's client-side tools. - Dict of {tool_name: ClientSideToolInfo}. + declared_tools: List of tool names (strings) or dicts with a 'name' field + from uipath__client_side_tools input. + agent_tools: The agent's client-side tools keyed by name. """ - declared: dict[str, dict[str, Any]] = {} - for i, t in enumerate(declared_tools): - if isinstance(t, dict): - if "name" not in t: - raise ValueError( - f"Client-side tool declaration at index {i} is missing required 'name' field." - ) - name = t["name"] - elif isinstance(t, str): - name = t - t = {"name": t} - else: - raise ValueError( - f"Client-side tool declaration at index {i} must be a dict or string, got {type(t).__name__}." - ) - if name in declared: - raise ValueError( - f"Duplicate client-side tool declaration: '{name}'." - ) - declared[name] = t - - required = set(agent_tools.keys()) - missing = required - set(declared.keys()) - if missing: - raise ValueError( - f"Missing required client-side tools: {', '.join(sorted(missing))}. " - f"The client must register handlers for all client-side tools defined by the agent." - ) - - for name, decl in declared.items(): - agent_tool = agent_tools.get(name) - if agent_tool is None: - continue # Unknown tool, runtime will ignore it - if decl.get("inputSchema") and agent_tool.get("input_schema"): - if json.dumps(decl["inputSchema"], sort_keys=True) != json.dumps( - agent_tool["input_schema"], sort_keys=True - ): - raise ValueError( - f"Client-side tool '{name}' inputSchema does not match agent definition." - ) - if decl.get("outputSchema") and agent_tool.get("output_schema"): - if json.dumps(decl["outputSchema"], sort_keys=True) != json.dumps( - agent_tool["output_schema"], sort_keys=True - ): - raise ValueError( - f"Client-side tool '{name}' outputSchema does not match agent definition." - ) - - available_client_side_tools.set(set(declared.keys())) + declared_names: set[str] = set() + for t in declared_tools: + if isinstance(t, str): + declared_names.add(t) + elif isinstance(t, dict) and "name" in t: + declared_names.add(t["name"]) + + available_client_side_tools.set(declared_names & set(agent_tools.keys())) def create_client_side_tool( diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index 198f65d35..afd7552f2 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -127,13 +127,21 @@ def request_approval( """ tool_call_id: str = tool_args.pop("tool_call_id") + # For server-side tools, is_execution_phase=True so the bridge emits + # executingToolCall at the confirmation interrupt. + # For client-side tools, is_execution_phase=False here because the + # execution interrupt in client_side_tool.py handles it separately. + is_execution_trigger = not (tool.metadata or {}).get( + IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, False + ) + @durable_interrupt def ask_confirmation(): return { "tool_call_id": tool_call_id, "tool_name": tool.name, "input": tool_args, - "is_execution_phase": False, + "is_execution_phase": is_execution_trigger, } response = ask_confirmation() diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index 60c4bb53b..fb041c9ac 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -475,7 +475,6 @@ async def map_current_message_to_start_tool_call_events(self): tool_call=UiPathConversationToolCallEvent( tool_call_id=tool_call["id"], executing=UiPathConversationExecutingToolCallEvent( - tool_name=tool_call["name"], input=tool_call["args"], ), ), diff --git a/tests/agent/tools/test_client_side_tool_validation.py b/tests/agent/tools/test_client_side_tool_validation.py index 96fed835a..b412bb20d 100644 --- a/tests/agent/tools/test_client_side_tool_validation.py +++ b/tests/agent/tools/test_client_side_tool_validation.py @@ -1,11 +1,9 @@ -"""Tests for client-side tool validation and filtering logic.""" - -import pytest +"""Tests for client-side tool filtering logic.""" from uipath_langchain.agent.tools.client_side_tool import ( ClientSideToolInfo, + apply_tool_filter, available_client_side_tools, - validate_and_apply_tool_filter, ) AGENT_TOOLS: dict[str, ClientSideToolInfo] = { @@ -26,117 +24,56 @@ } -class TestValidateAndApplyToolFilter: - """Tests for validate_and_apply_tool_filter.""" +class TestApplyToolFilter: + """Tests for apply_tool_filter.""" - def test_valid_declarations_set_filter(self): - declared = [ - {"name": "get_weather"}, - {"name": "show_map"}, - ] - validate_and_apply_tool_filter(declared, AGENT_TOOLS) + def test_all_agent_tools_declared(self): + apply_tool_filter(["get_weather", "show_map"], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather", "show_map"} - allowed = available_client_side_tools.get() - assert allowed == {"get_weather", "show_map"} + def test_subset_of_agent_tools(self): + """Client passes [A] when agent has [A, B] — only A available.""" + apply_tool_filter(["get_weather"], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather"} - def test_missing_required_tool_raises(self): - declared = [{"name": "get_weather"}] # missing show_map + def test_empty_list_means_no_tools(self): + """Client passes [] — no tools available.""" + apply_tool_filter([], AGENT_TOOLS) + assert available_client_side_tools.get() == set() - with pytest.raises(ValueError, match="Missing required client-side tools"): - validate_and_apply_tool_filter(declared, AGENT_TOOLS) + def test_unknown_names_ignored(self): + """Client passes [A, C, B] when agent has [A, B] — only [A, B].""" + apply_tool_filter(["get_weather", "unknown_tool", "show_map"], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather", "show_map"} - def test_input_schema_mismatch_raises(self): - declared = [ - { - "name": "get_weather", - "inputSchema": { - "type": "object", - "properties": {"location": {"type": "string"}}, - }, - }, - {"name": "show_map"}, - ] - - with pytest.raises(ValueError, match="inputSchema does not match"): - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - def test_output_schema_mismatch_raises(self): - declared = [ - { - "name": "get_weather", - "outputSchema": { - "type": "object", - "properties": {"temperature": {"type": "string"}}, - }, - }, - {"name": "show_map"}, - ] - - with pytest.raises(ValueError, match="outputSchema does not match"): - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - def test_unknown_extra_tools_are_ignored(self): - declared = [ - {"name": "get_weather"}, - {"name": "show_map"}, - {"name": "unknown_tool"}, - ] - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - allowed = available_client_side_tools.get() - assert allowed is not None - assert "unknown_tool" in allowed - assert "get_weather" in allowed - - def test_string_declarations_accepted(self): - declared = ["get_weather", "show_map"] - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - allowed = available_client_side_tools.get() - assert allowed == {"get_weather", "show_map"} - - def test_missing_name_field_raises(self): - declared = [{"inputSchema": {}}] - - with pytest.raises(ValueError, match="missing required 'name' field"): - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - def test_invalid_type_raises(self): - declared = [123] - - with pytest.raises(ValueError, match="must be a dict or string"): - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - def test_duplicate_name_raises(self): - declared = [ - {"name": "get_weather"}, - {"name": "get_weather"}, - {"name": "show_map"}, - ] - - with pytest.raises(ValueError, match="Duplicate client-side tool"): - validate_and_apply_tool_filter(declared, AGENT_TOOLS) - - def test_matching_schemas_pass(self): - declared = [ - { - "name": "get_weather", - "inputSchema": { - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - "outputSchema": { - "type": "object", - "properties": {"temp": {"type": "number"}}, - }, - }, - {"name": "show_map"}, - ] - validate_and_apply_tool_filter(declared, AGENT_TOOLS) + def test_only_unknown_names(self): + """Client passes only unknown names — empty set.""" + apply_tool_filter(["foo", "bar"], AGENT_TOOLS) + assert available_client_side_tools.get() == set() + + def test_dict_declarations_with_name(self): + """Dicts with 'name' field are accepted.""" + apply_tool_filter([{"name": "get_weather"}, {"name": "show_map"}], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather", "show_map"} + + def test_mixed_strings_and_dicts(self): + apply_tool_filter(["get_weather", {"name": "show_map"}], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather", "show_map"} + + def test_dicts_without_name_silently_skipped(self): + """Dicts missing 'name' are skipped, not errored.""" + apply_tool_filter([{"inputSchema": {}}, "get_weather"], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather"} + + def test_non_string_non_dict_silently_skipped(self): + """Invalid types are skipped, not errored.""" + apply_tool_filter([123, "get_weather"], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather"} - allowed = available_client_side_tools.get() - assert allowed is not None - assert "get_weather" in allowed + def test_duplicate_names_deduplicated(self): + """Duplicate names just collapse — no error.""" + apply_tool_filter(["get_weather", "get_weather", "show_map"], AGENT_TOOLS) + assert available_client_side_tools.get() == {"get_weather", "show_map"} class TestToolNotAvailableEnforcement: @@ -145,10 +82,12 @@ class TestToolNotAvailableEnforcement: def test_tool_not_in_allowed_set_returns_error(self): token = available_client_side_tools.set({"other_tool"}) try: - from unittest.mock import AsyncMock, patch - from uipath.agent.models.agent import AgentClientSideToolResourceConfig + from uipath_langchain.agent.tools.client_side_tool import ( + create_client_side_tool, + ) + resource = AgentClientSideToolResourceConfig( name="my_tool", description="A test tool", @@ -159,10 +98,6 @@ def test_tool_not_in_allowed_set_returns_error(self): output_schema=None, ) - from uipath_langchain.agent.tools.client_side_tool import ( - create_client_side_tool, - ) - tool = create_client_side_tool(resource) import asyncio @@ -177,17 +112,17 @@ def test_tool_not_in_allowed_set_returns_error(self): available_client_side_tools.reset(token) def test_tool_in_allowed_set_proceeds(self): - """When tool IS in the allowed set, it should NOT return an error. - - We can't fully test execution (it would hit durable_interrupt), - but we verify the availability check passes by patching the interrupt. - """ + """When tool IS in the allowed set, it should NOT return an error.""" token = available_client_side_tools.set({"my_tool"}) try: - from unittest.mock import AsyncMock, patch + from unittest.mock import patch from uipath.agent.models.agent import AgentClientSideToolResourceConfig + from uipath_langchain.agent.tools.client_side_tool import ( + create_client_side_tool, + ) + resource = AgentClientSideToolResourceConfig( name="my_tool", description="A test tool", @@ -198,15 +133,6 @@ def test_tool_in_allowed_set_proceeds(self): output_schema=None, ) - from uipath_langchain.agent.tools.client_side_tool import ( - create_client_side_tool, - ) - - tool = create_client_side_tool(resource) - - import asyncio - - # Patch durable_interrupt to avoid GraphInterrupt with ( patch( "uipath_langchain.agent.tools.client_side_tool.durable_interrupt", @@ -217,12 +143,12 @@ def test_tool_in_allowed_set_proceeds(self): side_effect=lambda **kw: lambda fn: fn, ), ): - # Re-create tool after patching tool = create_client_side_tool(resource) + import asyncio + result = asyncio.get_event_loop().run_until_complete( tool.coroutine(tool_call_id="tc-1", query="test") ) - # Should NOT be an error ToolMessage — it proceeded past the availability check if hasattr(result, "status"): assert result.status != "error" finally: @@ -232,8 +158,14 @@ def test_none_allowed_set_permits_all(self): """When available_client_side_tools is None (CAS default), all tools proceed.""" token = available_client_side_tools.set(None) try: + from unittest.mock import patch + from uipath.agent.models.agent import AgentClientSideToolResourceConfig + from uipath_langchain.agent.tools.client_side_tool import ( + create_client_side_tool, + ) + resource = AgentClientSideToolResourceConfig( name="any_tool", description="A test tool", @@ -244,12 +176,6 @@ def test_none_allowed_set_permits_all(self): output_schema=None, ) - from unittest.mock import patch - - from uipath_langchain.agent.tools.client_side_tool import ( - create_client_side_tool, - ) - with ( patch( "uipath_langchain.agent.tools.client_side_tool.durable_interrupt", diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 79b1a2c2d..381db2a7f 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -2229,7 +2229,7 @@ async def test_emits_executing_for_normal_tool(self): if e.tool_call is not None and e.tool_call.executing is not None ] assert len(executing_events) == 1 - assert executing_events[0].tool_call.executing.tool_name == "server_tool" + assert executing_events[0].tool_call.executing.input == {"x": 1} @pytest.mark.asyncio async def test_no_executing_for_confirmation_tool(self): From 451cd1fc445b1d3878f20f4d99f81c0c55d21e1b Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 11:36:01 -0400 Subject: [PATCH 08/12] refactor: send executing tool call event for unconfirmed tools --- src/uipath_langchain/agent/tools/client_side_tool.py | 8 +++----- src/uipath_langchain/chat/hitl.py | 9 --------- src/uipath_langchain/runtime/messages.py | 8 ++++---- tests/runtime/test_chat_message_mapper.py | 7 ++++--- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index cfe535297..7b70905b3 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -61,10 +61,9 @@ def create_client_side_tool( ) -> StructuredTool: """Create a client-side tool that pauses the graph and waits for the client to execute it. - The tool uses @durable_interrupt to suspend the graph. The client SDK receives - an executingToolCall event, runs its registered handler, and sends endToolCall - back through CAS. The bridge routes that endToolCall to wait_for_resume(), - which unblocks the graph with the client's result. + The tool uses @durable_interrupt to suspend the graph. The client receives + an executingToolCall event, executes its registered handler, and sends + endToolCall back through CAS. """ tool_name = sanitize_tool_name(resource.name) input_model = create_model_from_schema(resource.input_schema) @@ -96,7 +95,6 @@ async def wait_for_client_execution() -> dict[str, Any]: "tool_call_id": tool_call_id, "tool_name": tool_name, "input": kwargs, - "is_execution_phase": True, } result = await wait_for_client_execution() diff --git a/src/uipath_langchain/chat/hitl.py b/src/uipath_langchain/chat/hitl.py index afd7552f2..31a30e040 100644 --- a/src/uipath_langchain/chat/hitl.py +++ b/src/uipath_langchain/chat/hitl.py @@ -127,21 +127,12 @@ def request_approval( """ tool_call_id: str = tool_args.pop("tool_call_id") - # For server-side tools, is_execution_phase=True so the bridge emits - # executingToolCall at the confirmation interrupt. - # For client-side tools, is_execution_phase=False here because the - # execution interrupt in client_side_tool.py handles it separately. - is_execution_trigger = not (tool.metadata or {}).get( - IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, False - ) - @durable_interrupt def ask_confirmation(): return { "tool_call_id": tool_call_id, "tool_name": tool.name, "input": tool_args, - "is_execution_phase": is_execution_trigger, } response = ask_confirmation() diff --git a/src/uipath_langchain/runtime/messages.py b/src/uipath_langchain/runtime/messages.py index fb041c9ac..27f84619a 100644 --- a/src/uipath_langchain/runtime/messages.py +++ b/src/uipath_langchain/runtime/messages.py @@ -465,10 +465,10 @@ async def map_current_message_to_start_tool_call_events(self): ) ) - # Emit executingToolCall from MessageMapper for tools without - # a durable interrupt. Tools with interrupts (client-side, HITL) - # get executingToolCall from the bridge instead. - if not require_confirmation and not is_client_side: + # Emit executingToolCall for tools without confirmation. + # Confirmed tools get executingToolCall from the runtime + # loop after the confirmation resumes. + if not require_confirmation: events.append( UiPathConversationMessageEvent( message_id=self.current_message.id, diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 381db2a7f..8d6107394 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -2259,8 +2259,8 @@ async def test_no_executing_for_confirmation_tool(self): assert len(executing_events) == 0 @pytest.mark.asyncio - async def test_no_executing_for_client_side_tool(self): - """Should NOT emit executingToolCall for a client-side tool (bridge handles it).""" + async def test_emits_executing_for_client_side_tool(self): + """Should emit executingToolCall for a client-side tool without confirmation.""" storage = create_mock_storage() storage.get_value.return_value = {} mapper = UiPathChatMessagesMapper("test-runtime", storage) @@ -2287,7 +2287,8 @@ async def test_no_executing_for_client_side_tool(self): for e in result if e.tool_call is not None and e.tool_call.executing is not None ] - assert len(executing_events) == 0 + assert len(executing_events) == 1 + assert executing_events[0].tool_call.executing.input == {"title": "Avatar"} class TestClientSideToolEndSuppression: From d5b66d17471f3b0d7164d1b9a9f359b8bed9db67 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 15:27:56 -0400 Subject: [PATCH 09/12] refactor: rename and add is error check --- src/uipath_langchain/agent/react/agent.py | 10 +++++----- .../agent/tools/client_side_tool.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 5f6511779..82bf84ee0 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -81,22 +81,22 @@ def create_agent( llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools] # Derive client-side tool schemas from tools for input validation in the init node. - cs_tools: dict[str, ClientSideToolInfo] | None = None + conversational_client_side_tools: dict[str, ClientSideToolInfo] | None = None if config.is_conversational: - cs_tools = {} + conversational_client_side_tools = {} for t in agent_tools: meta = getattr(t, "metadata", None) or {} if meta.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL): - cs_tools[t.name] = { + conversational_client_side_tools[t.name] = { "input_schema": t.args_schema.model_json_schema() if hasattr(t, "args_schema") and t.args_schema else None, "output_schema": meta.get("output_schema"), } - cs_tools = cs_tools or None + conversational_client_side_tools = conversational_client_side_tools or None init_node = create_init_node( - messages, input_schema, config.is_conversational, cs_tools + messages, input_schema, config.is_conversational, conversational_client_side_tools ) tool_nodes = create_tool_node(agent_tools) diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index 7b70905b3..6471c7d7e 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -98,21 +98,25 @@ async def wait_for_client_execution() -> dict[str, Any]: } result = await wait_for_client_execution() - return result.get("output", result) if isinstance(result, dict) else result + return result if isinstance(result, dict) else {"output": result} result = await execute_tool() - if isinstance(result, dict): + is_error = result.get("isError", False) + output = result.get("output", result) + + if isinstance(output, dict): try: - content = json.dumps(result) + content = json.dumps(output) except TypeError: - content = str(result) + content = str(output) else: - content = str(result) if result is not None else "" + content = str(output) if output is not None else "" return ToolMessage( content=content, tool_call_id=tool_call_id, + status="error" if is_error else "success", response_metadata={IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True}, ) From 4a664cf81c08929f076e1c4ca3a547e4e15f89a7 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 29 May 2026 13:07:46 -0400 Subject: [PATCH 10/12] chore: update uv lock --- pyproject.toml | 8 ++++---- uv.lock | 26 +++++++++++++------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 95bcaed7a..6aba31036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "uipath-langchain" -version = "0.11.10" +version = "0.11.11" 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.72, <2.11.0", - "uipath-core>=0.5.15, <0.6.0", + "uipath>=2.10.74, <2.11.0", + "uipath-core>=0.5.17, <0.6.0", "uipath-platform>=0.1.59, <0.2.0", - "uipath-runtime>=0.10.0, <0.11.0", + "uipath-runtime>=0.11.0, <0.12.0", "langgraph>=1.1.8, <2.0.0", "langchain-core>=1.2.11, <2.0.0", "langgraph-checkpoint-sqlite>=3.0.3, <4.0.0", diff --git a/uv.lock b/uv.lock index e7f67122d..01a3ae4b0 100644 --- a/uv.lock +++ b/uv.lock @@ -4344,7 +4344,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.72" +version = "2.10.74" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4367,28 +4367,28 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/9c/8a14a8b3c0ad5d012283a3e8bb4d908729a3223d19c337e1ff44eefa79af/uipath-2.10.72.tar.gz", hash = "sha256:102b1eacec0cc84b91a13586db2c94797cffb52121683954ac73c1f6e5bdc36f", size = 2945990, upload-time = "2026-05-28T12:51:12.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/55/8e00773946979f80934fd5ee0957f3e55b6a13a01b0de6b813a72caef001/uipath-2.10.74.tar.gz", hash = "sha256:6fa9d677559562c7ca0faef51660dfdc4803b744c233af6453d410b06c60ef1a", size = 2947956, upload-time = "2026-05-29T16:18:42.389Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/0d/3176ee9cf5e78cfc54b1bd20a9a50de7e6962f0ea04fe747b2aab0ad358f/uipath-2.10.72-py3-none-any.whl", hash = "sha256:66496d6d5f9590c91a228ca41e979af4cc7c1e148c49c2b5da314d834dfb10b8", size = 391720, upload-time = "2026-05-28T12:51:11.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/40029c2447610348bbae1049f398ee194396d04b170af44c20a235869b36/uipath-2.10.74-py3-none-any.whl", hash = "sha256:fc8033cbe634bc0dabe2c9f8d2cee9f9ddef439aad207bcc2bdcbfb4f491388c", size = 391889, upload-time = "2026-05-29T16:18:40.047Z" }, ] [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/fe/3f97b915c8cb13e75eb9939d442c165647386b4d62a41992188e70df3f9f/uipath_core-0.5.16.tar.gz", hash = "sha256:672d264900a968a10a777fe32d10f522ed8ef7fb7aebdfb765426bf50a1ffa16", size = 118786, upload-time = "2026-05-22T16:43:27.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/80/a626eb3136a6765e0af06c9d5080ac0843c2a72f17b7a2170f1f45da40dd/uipath_core-0.5.17.tar.gz", hash = "sha256:13565e1eba9f059a8221494dfb3239257ddf7f265fc7057199ffe03ed066300a", size = 119023, upload-time = "2026-05-28T21:34:10.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/6c/ac4ebc1e4ae03c471fe4a241ae9a0c53849a8254418feb450d2c5098d8aa/uipath_core-0.5.16-py3-none-any.whl", hash = "sha256:f9d420fd188a1e03f3e56fc4a40df5f2ec2359ff7cce666086288d3a8ace07df", size = 44673, upload-time = "2026-05-22T16:43:26.194Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/f4b481970621e2a9aec869302773fa2c7d346aef294a553429626369633f/uipath_core-0.5.17-py3-none-any.whl", hash = "sha256:6e088eec5130bc492ac176ab85d4924d7d4cb07ee290ed7e6a46984e9de8c12b", size = 44957, upload-time = "2026-05-28T21:34:09.534Z" }, ] [[package]] name = "uipath-langchain" -version = "0.11.10" +version = "0.11.11" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4463,8 +4463,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.72,<2.11.0" }, - { name = "uipath-core", specifier = ">=0.5.15,<0.6.0" }, + { name = "uipath", specifier = ">=2.10.74,<2.11.0" }, + { name = "uipath-core", specifier = ">=0.5.17,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.13.0,<1.14.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.13.0,<1.14.0" }, { name = "uipath-langchain-client", extras = ["bedrock"], marker = "extra == 'bedrock'", specifier = ">=1.13.0,<1.14.0" }, @@ -4473,7 +4473,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.13.0,<1.14.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.13.0,<1.14.0" }, { name = "uipath-platform", specifier = ">=0.1.59,<0.2.0" }, - { name = "uipath-runtime", specifier = ">=0.10.0,<0.11.0" }, + { name = "uipath-runtime", specifier = ">=0.11.0,<0.12.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] @@ -4573,14 +4573,14 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.10.1" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/72/8d/4d36d6a5dda4ca5f25e52508bc20dd82cb92fcdf2a36cd0adc4f9832d047/uipath_runtime-0.11.0.tar.gz", hash = "sha256:cc94f2fdab43b593ef678eff904fc6cdd4831963cffe39a83909ffcf9082d76f", size = 143685, upload-time = "2026-05-29T15:13:30.562Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/e7/08/c7b90851d4544ff5e76ca7c55452597aae1619cf1ebc2c0aa7b098110f14/uipath_runtime-0.11.0-py3-none-any.whl", hash = "sha256:08bf53a0e38bb3d19edc6708d2ecb7d918aa96fdda13e35f3ad0e6f2a6c392b9", size = 43770, upload-time = "2026-05-29T15:13:29.282Z" }, ] [[package]] From 865a9ab5ba4c39b5d6677064e89a16ba4109dcad Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 29 May 2026 13:28:55 -0400 Subject: [PATCH 11/12] test: formatting and tests --- src/uipath_langchain/agent/react/agent.py | 9 +++++++-- src/uipath_langchain/runtime/runtime.py | 7 +++++-- tests/agent/tools/test_client_side_tool_validation.py | 5 ++++- tests/runtime/test_chat_message_mapper.py | 4 ++++ tests/runtime/test_client_side_tool_discovery.py | 2 ++ 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 82bf84ee0..e8e186f0f 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -89,14 +89,19 @@ def create_agent( if meta.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL): conversational_client_side_tools[t.name] = { "input_schema": t.args_schema.model_json_schema() - if hasattr(t, "args_schema") and t.args_schema + if hasattr(t, "args_schema") + and t.args_schema + and hasattr(t.args_schema, "model_json_schema") else None, "output_schema": meta.get("output_schema"), } conversational_client_side_tools = conversational_client_side_tools or None init_node = create_init_node( - messages, input_schema, config.is_conversational, conversational_client_side_tools + messages, + input_schema, + config.is_conversational, + conversational_client_side_tools, ) tool_nodes = create_tool_node(agent_tools) diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 2baef462e..a51de93d6 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -32,7 +32,6 @@ from uipath.runtime.schema import UiPathRuntimeSchema from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo -from uipath_langchain.agent.tools.tool_node import RunnableCallableWithTool from uipath_langchain.chat.hitl import ( IS_CONVERSATIONAL_CLIENT_SIDE_TOOL, get_confirmation_schema, @@ -530,7 +529,11 @@ def _get_client_side_tools(self) -> dict[str, ClientSideToolInfo]: metadata = getattr(tool, "metadata", None) or {} if metadata.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL): input_schema = None - if hasattr(tool, "args_schema") and tool.args_schema: + if ( + hasattr(tool, "args_schema") + and tool.args_schema + and hasattr(tool.args_schema, "model_json_schema") + ): input_schema = tool.args_schema.model_json_schema() tools[tool.name] = { "input_schema": input_schema, diff --git a/tests/agent/tools/test_client_side_tool_validation.py b/tests/agent/tools/test_client_side_tool_validation.py index b412bb20d..1b4d4163c 100644 --- a/tests/agent/tools/test_client_side_tool_validation.py +++ b/tests/agent/tools/test_client_side_tool_validation.py @@ -67,7 +67,7 @@ def test_dicts_without_name_silently_skipped(self): def test_non_string_non_dict_silently_skipped(self): """Invalid types are skipped, not errored.""" - apply_tool_filter([123, "get_weather"], AGENT_TOOLS) + apply_tool_filter([123, "get_weather"], AGENT_TOOLS) # type: ignore[list-item] assert available_client_side_tools.get() == {"get_weather"} def test_duplicate_names_deduplicated(self): @@ -102,6 +102,7 @@ def test_tool_not_in_allowed_set_returns_error(self): import asyncio + assert tool.coroutine is not None result = asyncio.get_event_loop().run_until_complete( tool.coroutine(tool_call_id="tc-1", query="test") ) @@ -146,6 +147,7 @@ def test_tool_in_allowed_set_proceeds(self): tool = create_client_side_tool(resource) import asyncio + assert tool.coroutine is not None result = asyncio.get_event_loop().run_until_complete( tool.coroutine(tool_call_id="tc-1", query="test") ) @@ -190,6 +192,7 @@ def test_none_allowed_set_permits_all(self): import asyncio + assert tool.coroutine is not None result = asyncio.get_event_loop().run_until_complete( tool.coroutine(tool_call_id="tc-1", q="test") ) diff --git a/tests/runtime/test_chat_message_mapper.py b/tests/runtime/test_chat_message_mapper.py index 8d6107394..530bd8c68 100644 --- a/tests/runtime/test_chat_message_mapper.py +++ b/tests/runtime/test_chat_message_mapper.py @@ -2229,6 +2229,8 @@ async def test_emits_executing_for_normal_tool(self): if e.tool_call is not None and e.tool_call.executing is not None ] assert len(executing_events) == 1 + assert executing_events[0].tool_call is not None + assert executing_events[0].tool_call.executing is not None assert executing_events[0].tool_call.executing.input == {"x": 1} @pytest.mark.asyncio @@ -2288,6 +2290,8 @@ async def test_emits_executing_for_client_side_tool(self): if e.tool_call is not None and e.tool_call.executing is not None ] assert len(executing_events) == 1 + assert executing_events[0].tool_call is not None + assert executing_events[0].tool_call.executing is not None assert executing_events[0].tool_call.executing.input == {"title": "Avatar"} diff --git a/tests/runtime/test_client_side_tool_discovery.py b/tests/runtime/test_client_side_tool_discovery.py index 1fa3008d0..02e81187c 100644 --- a/tests/runtime/test_client_side_tool_discovery.py +++ b/tests/runtime/test_client_side_tool_discovery.py @@ -86,6 +86,8 @@ def test_schemas_are_preserved(self): assert tool_info is not None assert "output_schema" in tool_info assert "input_schema" in tool_info + assert tool_info["output_schema"] is not None + assert tool_info["input_schema"] is not None assert "rating" in tool_info["output_schema"]["properties"] assert "title" in tool_info["input_schema"]["properties"] From 9a2f2f6d23a764780b142c9a218237c50624aef4 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Fri, 29 May 2026 13:42:39 -0400 Subject: [PATCH 12/12] test: update tests --- tests/agent/react/test_create_agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/agent/react/test_create_agent.py b/tests/agent/react/test_create_agent.py index cfe9cc4fc..fb611c8d6 100644 --- a/tests/agent/react/test_create_agent.py +++ b/tests/agent/react/test_create_agent.py @@ -151,6 +151,7 @@ def test_autonomous_agent_with_tools( messages, None, # input schema False, # is_conversational + None, # client_side_tools ) mock_create_terminate_node.assert_called_once_with( None, # output schema @@ -246,6 +247,7 @@ def test_conversational_agent_with_tools( messages, None, # input schema True, # is_conversational + None, # client_side_tools ) mock_create_terminate_node.assert_called_once_with( None, # output schema