Skip to content

feat: add client side tools to mapper and runtime [JAR-9629]#856

Open
norman-le wants to merge 5 commits into
mainfrom
feat/jar-9629-client-side-tools-cas-urt-v2
Open

feat: add client side tools to mapper and runtime [JAR-9629]#856
norman-le wants to merge 5 commits into
mainfrom
feat/jar-9629-client-side-tools-cas-urt-v2

Conversation

@norman-le
Copy link
Copy Markdown
Contributor

@norman-le norman-le commented May 18, 2026

Had to make new PR (old one here: #819)

## uipath-python

- Added ClientSide tool type to agent model so the runtime can distinguish client-side tools from server tools
- Bridge emits executingToolCall event on durable interrupt when is_execution_phase marker is set, enabling the client to know when to execute the tool
- Bridge routes endToolCall from the client back to the agent, completing the interrupt/resume cycle

## uipath-langchain-python

- Client-side tool factory creates StructuredTool instances that use @durable_interrupt to pause the graph and wait for client execution
- @mockable wraps @durable_interrupt (as a separate outer function) so evals can simulate client-side tools without hitting GraphInterrupt
- CLIENT_SIDE_TOOL_MARKER defined in hitl.py as the single source of truth for identifying client-side tools across the codebase
- Error-handling wrapper (RunnableCallableWithTool) now preserves .tool reference for client-side tools, enabling runtime discovery
- Runtime discovers client-side tools from the compiled graph via shared _iter_graph_tools() iterator, used by both confirmation and client-side tool detection
- MessageMapper emits executingToolCall only for server tools without interrupts; client-side and confirmation tools get it from the bridge instead
- MessageMapper suppresses endToolCall for client-side tool results since the client already produced the result
- MessageMapper sets isClientSideTool and outputSchema on startToolCall events so the client knows to show the input form
- 9 new tests covering tool discovery, executingToolCall emission rules, and endToolCall suppression

## uipath-agents-python

- Graph builder strips CLIENT_SIDE_TOOL_MARKER during evals so @durable_interrupt is bypassed and @mockable can intercept

## AgentInterfaces

- CAS tracks is_client_side_tool on tool calls in the database and forwards client-originated endToolCall to the agent
- CAS validates client-side tool declarations against the agent definition and guards against unauthorized endToolCall for non-client-side tools
- React SDK listens for executingToolCall events and renders a form (ClientSideToolWidget) using the tool's output schema for the user to fill in
- ClientSideToolWidget reuses the existing ToolConfirmationFormContent component
- Added type schemas for executingToolCall events and client-side tool declarations

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:
image

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

Copilot AI review requested due to automatic review settings May 18, 2026 15:02
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 90%)

See analysis details on SonarQube Cloud

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class support for “client-side tools” across agent construction, runtime tool discovery, and chat event mapping so the client SDK can execute certain tool calls and send results back through the interrupt/resume cycle.

Changes:

  • Introduces a client-side tool factory that uses @durable_interrupt and marks tool/messages with a shared IS_CONVERSATIONAL_CLIENT_SIDE_TOOL key.
  • Enhances runtime tool discovery to extract client-side tool schemas from compiled graphs and exposes them to the message mapper.
  • Updates UiPathChatMessagesMapper to emit executingToolCall only for server tools (no interrupts) and to suppress endToolCall for client-side tool results; adds tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/uipath_langchain/agent/tools/client_side_tool.py Adds client-side tool creation, marker metadata, and client declaration validation/filtering.
src/uipath_langchain/agent/tools/tool_factory.py Wires AgentClientSideToolResourceConfig to the new client-side tool factory.
src/uipath_langchain/agent/tools/tool_node.py Preserves .tool reference through error-handling wrapper for client-side tools (for runtime discovery).
src/uipath_langchain/runtime/runtime.py Adds graph tool iterator + client-side tool discovery; injects discovered info into the chat mapper.
src/uipath_langchain/runtime/messages.py Emits executingToolCall only for non-interrupting tools; includes client-side schema fields on start events; suppresses client-side end events.
src/uipath_langchain/chat/hitl.py Defines shared IS_CONVERSATIONAL_CLIENT_SIDE_TOOL marker and adds is_execution_phase flag for interrupts.
src/uipath_langchain/agent/react/init_node.py Validates client-side tool declarations from exchange input and applies per-exchange availability filter.
src/uipath_langchain/agent/react/agent.py Derives client-side tool schemas from built tools and passes them into init-node validation.
tests/runtime/test_client_side_tool_discovery.py Adds integration-guard tests for client-side tool discovery through wrappers.
tests/runtime/test_chat_message_mapper.py Adds tests for executingToolCall emission rules and client-side endToolCall suppression.

Comment on lines +50 to +55
declared = {
(t["name"] if isinstance(t, dict) else t): t
if isinstance(t, dict)
else {"name": t}
for t in declared_tools
}
Comment on lines +76 to +78
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)
Comment on lines +446 to +451
(
client_tool_info.get("output_schema")
if isinstance(client_tool_info, dict)
else client_tool_info
)
if is_client_side
Comment on lines +34 to +110
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,
) -> 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:
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",
)

@@ -1,4 +1,4 @@
from typing import Callable, Sequence, Type, TypeVar
from typing import Any, Callable, Sequence, Type, TypeVar
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants