Skip to content

Commit 6cbcef2

Browse files
committed
feat: add client side tools to mapper and runtime
1 parent a3c7f53 commit 6cbcef2

5 files changed

Lines changed: 182 additions & 11 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Factory for creating client-side tools that execute on the client SDK."""
2+
3+
import inspect
4+
import json
5+
from logging import getLogger
6+
from typing import Annotated, Any
7+
8+
from langchain_core.messages import ToolMessage
9+
from langchain_core.tools import InjectedToolCallId, StructuredTool
10+
from uipath.agent.models.agent import AgentClientSideToolResourceConfig
11+
from uipath.eval.mocks import mockable
12+
13+
from uipath_langchain._utils.durable_interrupt import durable_interrupt
14+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
15+
create_model as create_model_from_schema,
16+
)
17+
18+
from .utils import sanitize_tool_name
19+
20+
logger = getLogger(__name__)
21+
22+
CLIENT_SIDE_TOOL_MARKER = "uipath_client_tool"
23+
24+
25+
def create_client_side_tool(
26+
resource: AgentClientSideToolResourceConfig,
27+
) -> StructuredTool:
28+
"""Create a client-side tool that pauses the graph and waits for the client to execute it.
29+
30+
The tool uses @durable_interrupt to suspend the graph. The client SDK receives
31+
an executingToolCall event, runs its registered handler, and sends endToolCall
32+
back through CAS. The bridge routes that endToolCall to wait_for_resume(),
33+
which unblocks the graph with the client's result.
34+
"""
35+
tool_name = sanitize_tool_name(resource.name)
36+
input_model = create_model_from_schema(resource.input_schema)
37+
38+
async def client_side_tool_fn(
39+
*, tool_call_id: Annotated[str, InjectedToolCallId], **kwargs: Any
40+
) -> Any:
41+
@mockable(
42+
name=resource.name,
43+
description=resource.description,
44+
input_schema=input_model.model_json_schema(),
45+
output_schema=(resource.output_schema or {}),
46+
example_calls=getattr(resource.properties, 'example_calls', None),
47+
)
48+
@durable_interrupt
49+
async def wait_for_client_execution() -> dict[str, Any]:
50+
return {
51+
"tool_call_id": tool_call_id,
52+
"tool_name": tool_name,
53+
"input": kwargs,
54+
"is_execution_phase": True,
55+
}
56+
57+
# First run: raises GraphInterrupt with the tool call info.
58+
# On resume: returns the client's result (output, isError, etc.)
59+
# During evals: @mockable intercepts and returns simulated response.
60+
result = await wait_for_client_execution()
61+
62+
# The resume value from the bridge is the endToolCall payload
63+
output = result.get("output")
64+
is_error = result.get("is_error", False)
65+
66+
content = str(output) if output is not None else ""
67+
if isinstance(output, dict):
68+
content = json.dumps(output)
69+
70+
return ToolMessage(
71+
content=content,
72+
tool_call_id=tool_call_id,
73+
status="error" if is_error else "success",
74+
response_metadata={CLIENT_SIDE_TOOL_MARKER: True},
75+
)
76+
77+
# Patch signature so LangChain injects tool_call_id at runtime
78+
original_sig = inspect.signature(client_side_tool_fn)
79+
params = [p for p in original_sig.parameters.values() if p.name != "kwargs"] + [
80+
inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD, annotation=Any),
81+
]
82+
client_side_tool_fn.__signature__ = original_sig.replace(parameters=params)
83+
84+
tool = StructuredTool(
85+
name=tool_name,
86+
description=resource.description or f"Client-side tool: {tool_name}",
87+
args_schema=input_model,
88+
coroutine=client_side_tool_fn,
89+
metadata={
90+
CLIENT_SIDE_TOOL_MARKER: True,
91+
"output_schema": resource.output_schema,
92+
},
93+
)
94+
95+
return tool

src/uipath_langchain/agent/tools/tool_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from langchain_core.language_models import BaseChatModel
66
from langchain_core.tools import BaseTool
77
from uipath.agent.models.agent import (
8+
AgentClientSideToolResourceConfig,
89
AgentContextResourceConfig,
910
AgentEscalationResourceConfig,
1011
AgentIntegrationToolResourceConfig,
@@ -18,6 +19,7 @@
1819

1920
from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION
2021

22+
from .client_side_tool import create_client_side_tool
2123
from .context_tool import create_context_tool
2224
from .escalation_tool import create_escalation_tool
2325
from .extraction_tool import create_ixp_extraction_tool
@@ -120,4 +122,7 @@ async def _build_tool_for_resource(
120122
elif isinstance(resource, AgentIxpVsEscalationResourceConfig):
121123
return create_ixp_escalation_tool(resource)
122124

125+
elif isinstance(resource, AgentClientSideToolResourceConfig):
126+
return create_client_side_tool(resource)
127+
123128
return None

src/uipath_langchain/chat/hitl.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,18 @@ def request_approval(
126126
"""
127127
tool_call_id: str = tool_args.pop("tool_call_id")
128128

129+
# If this is a server-side tool (not client-side), execution follows immediately
130+
# after confirmation — mark this as the execution trigger so the bridge emits
131+
# executingToolCall. For client-side tools, the execution interrupt sets this instead.
132+
is_execution_trigger = not (tool.metadata or {}).get("uipath_client_tool", False)
133+
129134
@durable_interrupt
130135
def ask_confirmation():
131136
return {
132137
"tool_call_id": tool_call_id,
133138
"tool_name": tool.name,
134139
"input": tool_args,
140+
"is_execution_phase": is_execution_trigger,
135141
}
136142

137143
response = ask_confirmation()

src/uipath_langchain/runtime/messages.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
UiPathConversationContentPartEndEvent,
2525
UiPathConversationContentPartEvent,
2626
UiPathConversationContentPartStartEvent,
27+
UiPathConversationExecutingToolCallEvent,
2728
UiPathConversationMessage,
2829
UiPathConversationMessageData,
2930
UiPathConversationMessageEndEvent,
@@ -60,6 +61,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None
6061
self.storage = storage
6162
self.current_message: AIMessageChunk | AIMessage
6263
self.tools_requiring_confirmation: dict[str, Any] = {}
64+
self.client_side_tools: dict[str, Any] = {} # {tool_name: output_schema}
6365
self.seen_message_ids: set[str] = set()
6466
self._storage_lock = asyncio.Lock()
6567
self._citation_stream_processor = CitationStreamProcessor()
@@ -436,15 +438,39 @@ async def map_current_message_to_start_tool_call_events(self):
436438
tool_name in self.tools_requiring_confirmation
437439
)
438440
input_schema = self.tools_requiring_confirmation.get(tool_name)
441+
is_client_side = tool_name in self.client_side_tools
442+
output_schema = (
443+
self.client_side_tools.get(tool_name)
444+
if is_client_side
445+
else None
446+
)
439447
events.append(
440448
self.map_tool_call_to_tool_call_start_event(
441449
self.current_message.id,
442450
tool_call,
443451
require_confirmation=require_confirmation or None,
444452
input_schema=input_schema,
453+
is_client_side_tool=is_client_side or None,
454+
output_schema=output_schema,
445455
)
446456
)
447457

458+
# Emit executingToolCall from MessageMapper since there's no durable interrupt
459+
# to trigger it from the runtime loop.
460+
if not require_confirmation and not is_client_side:
461+
events.append(
462+
UiPathConversationMessageEvent(
463+
message_id=self.current_message.id,
464+
tool_call=UiPathConversationToolCallEvent(
465+
tool_call_id=tool_call["id"],
466+
executing=UiPathConversationExecutingToolCallEvent(
467+
tool_name=tool_call["name"],
468+
input=tool_call["args"],
469+
),
470+
),
471+
)
472+
)
473+
448474
if self.storage is not None:
449475
await self.storage.set_value(
450476
self.runtime_id,
@@ -476,19 +502,24 @@ async def map_tool_message_to_events(
476502
# Keep as string if not valid JSON
477503
pass
478504

479-
events = [
480-
UiPathConversationMessageEvent(
481-
message_id=message_id,
482-
tool_call=UiPathConversationToolCallEvent(
483-
tool_call_id=message.tool_call_id,
484-
end=UiPathConversationToolCallEndEvent(
485-
timestamp=self.get_timestamp(),
486-
output=content_value,
487-
is_error=message.status == "error",
505+
# Suppress endToolCall for client-side tools — the client already has the result (it produced it).
506+
is_client_side = message.response_metadata.get("uipath_client_tool", False)
507+
events: list[UiPathConversationMessageEvent] = []
508+
509+
if not is_client_side:
510+
events.append(
511+
UiPathConversationMessageEvent(
512+
message_id=message_id,
513+
tool_call=UiPathConversationToolCallEvent(
514+
tool_call_id=message.tool_call_id,
515+
end=UiPathConversationToolCallEndEvent(
516+
timestamp=self.get_timestamp(),
517+
output=content_value,
518+
is_error=message.status == "error",
519+
),
488520
),
489-
),
521+
)
490522
)
491-
]
492523

493524
if is_last_tool_call:
494525
events.append(self.map_to_message_end_event(message_id))
@@ -546,6 +577,8 @@ def map_tool_call_to_tool_call_start_event(
546577
*,
547578
require_confirmation: bool | None = None,
548579
input_schema: Any | None = None,
580+
is_client_side_tool: bool | None = None,
581+
output_schema: Any | None = None,
549582
) -> UiPathConversationMessageEvent:
550583
return UiPathConversationMessageEvent(
551584
message_id=message_id,
@@ -557,6 +590,8 @@ def map_tool_call_to_tool_call_start_event(
557590
input=tool_call["args"],
558591
require_confirmation=require_confirmation,
559592
input_schema=input_schema,
593+
is_client_side_tool=is_client_side_tool,
594+
output_schema=output_schema,
560595
),
561596
),
562597
)

src/uipath_langchain/runtime/runtime.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131
from uipath.runtime.schema import UiPathRuntimeSchema
3232

33+
from uipath_langchain.agent.tools.client_side_tool import CLIENT_SIDE_TOOL_MARKER
3334
from uipath_langchain.agent.tools.tool_node import RunnableCallableWithTool
3435
from uipath_langchain.chat.hitl import get_confirmation_schema
3536
from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError
@@ -68,6 +69,7 @@ def __init__(
6869
self.callbacks: list[BaseCallbackHandler] = callbacks or []
6970
self.chat = UiPathChatMessagesMapper(self.runtime_id, storage)
7071
self.chat.tools_requiring_confirmation = self._get_tool_confirmation_info()
72+
self.chat.client_side_tools = self._get_client_side_tools()
7173
self._middleware_node_names: set[str] = self._detect_middleware_nodes()
7274

7375
async def execute(
@@ -522,6 +524,34 @@ def _get_tool_confirmation_info(self) -> dict[str, Any]:
522524

523525
return schemas
524526

527+
def _get_client_side_tools(self) -> dict[str, Any]:
528+
"""Build {tool_name: output_schema} for client-side tools from compiled graph nodes."""
529+
530+
tools: dict[str, Any] = {}
531+
for node_name, node_spec in self.graph.nodes.items():
532+
bound = getattr(node_spec, "bound", None)
533+
if bound is None:
534+
continue
535+
536+
tool = getattr(bound, "tool", None)
537+
if tool is not None:
538+
metadata = getattr(tool, "metadata", None) or {}
539+
if metadata.get(CLIENT_SIDE_TOOL_MARKER):
540+
name = getattr(tool, "name", node_name)
541+
tools[name] = metadata.get("output_schema")
542+
continue
543+
544+
tools_by_name = getattr(bound, "tools_by_name", None)
545+
if isinstance(tools_by_name, dict):
546+
for name, tool in tools_by_name.items():
547+
metadata = getattr(tool, "metadata", None) or {}
548+
if metadata.get(CLIENT_SIDE_TOOL_MARKER):
549+
tools[str(getattr(tool, "name", name))] = metadata.get(
550+
"output_schema"
551+
)
552+
553+
return tools
554+
525555
def _is_middleware_node(self, node_name: str) -> bool:
526556
"""Check if a node name represents a middleware node."""
527557
return node_name in self._middleware_node_names

0 commit comments

Comments
 (0)