feat: add client side tools to mapper and runtime#819
Conversation
There was a problem hiding this comment.
Pull request overview
Adds first-class support for “client-side tools” (tools executed by the client SDK) so the runtime can advertise them in tool-call start events and adjust tool-call lifecycle events accordingly.
Changes:
- Introduces a new
client_side_toolfactory that suspends execution via@durable_interruptand resumes with client-provided results. - Extends tool creation to support
AgentClientSideToolResourceConfig. - Enhances runtime + message mapping to (a) discover client-side tools/output schemas from the compiled graph, (b) include client-side metadata in tool-call start events, (c) emit
executingToolCallfor server-side tools without confirmation, and (d) suppressendToolCallevents for client-side tools.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/uipath_langchain/runtime/runtime.py |
Discovers client-side tools from compiled graph nodes and passes them into the message mapper. |
src/uipath_langchain/runtime/messages.py |
Adds client-side tool metadata to tool-call start events, emits executingToolCall for non-confirmation server tools, suppresses endToolCall for client-side tools. |
src/uipath_langchain/chat/hitl.py |
Marks confirmation interrupts as “execution phase” triggers for server-side tools (not client-side). |
src/uipath_langchain/agent/tools/tool_factory.py |
Wires new client-side tool resource config into tool creation. |
src/uipath_langchain/agent/tools/client_side_tool.py |
Implements the client-side tool behavior using @durable_interrupt + ToolMessage result annotation. |
| 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() |
| # 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( |
| # 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( |
| # 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) |
|
|
||
| from .utils import sanitize_tool_name | ||
|
|
||
| logger = getLogger(__name__) |
|
|
||
| content = str(output) if output is not None else "" | ||
| if isinstance(output, dict): | ||
| content = json.dumps(output) |
| elif isinstance(resource, AgentClientSideToolResourceConfig): | ||
| return create_client_side_tool(resource) | ||
|
|
| 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) |
There was a problem hiding this comment.
Hm a bit confused what this is for
| 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) |
There was a problem hiding this comment.
use CLIENT_SIDE_TOOL_MARKER
|
| args_schema=input_model, | ||
| coroutine=client_side_tool_fn, | ||
| metadata={ | ||
| CLIENT_SIDE_TOOL_MARKER: True, |
There was a problem hiding this comment.
Maybe a more consistent name compared to the other markers, which include CONVERSATIONAL? I'm thinking IS_CONVERSATIONAL_CLIENT_SIDE_TOOL?
| # 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( | ||
| 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"], |
There was a problem hiding this comment.
Hm, I guess related to my comment here. Personally would prefer if a single place creates/outputs the executingToolCall event, especially since right now it seems to be split across two repos and could add some confusion. Is there a code-place where any path (including tools with interrupts) eventually converge to once they are ready to run the tool?
There was a problem hiding this comment.
I wasnt able to get them to fire in a single place, because the MessageMapper processes the AIMessage before the tool node runs (MessageMapper sees tool calls at stream time), but the graph isn't ready to receive responses until interrupt time. For tools that don't need a response from the user, this doesn't matter. For tools that pause and wait, the emission must happen after the pause (tool confirmation and client side tools).
I added it in both places to keep the events consistent for tool calls, but it's a no-op for non interrupt tools anyways. I think if it's causing issues, removing it from non interrupt tools might be the better case so that users wont get confused and try to handle that event.


Videos in CAS localhost (Agent Builder changes already published, but this will be the debug experience as well when the sdk is upgraded):
Without tool confirmation for client side tool:
Screen.Recording.2026-05-13.at.9.32.26.PM.mov
With tool confirmation for client side tool:
Screen.Recording.2026-05-13.at.9.27.56.PM.mov
URT Eval:

Related to changes in other PRs:
CAS: https://github.com/UiPath/AgentInterfaces/pull/949
uipath-langchain-python (tool definition): #819
uipath-python (bridge changes): UiPath/uipath-python#1609
uipath-agents-python (skipping for evals, so it can be mocked): https://github.com/UiPath/uipath-agents-python/pull/485
Another Agents PR fixing some issues with debug: https://github.com/UiPath/Agents/pull/5250