Skip to content

Commit e194eb8

Browse files
authored
feat: add client side tools to mapper and runtime [JAR-9629] (#856)
1 parent f4295f5 commit e194eb8

14 files changed

Lines changed: 765 additions & 54 deletions

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.11.10"
3+
version = "0.11.11"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath>=2.10.72, <2.11.0",
9-
"uipath-core>=0.5.15, <0.6.0",
8+
"uipath>=2.10.74, <2.11.0",
9+
"uipath-core>=0.5.17, <0.6.0",
1010
"uipath-platform>=0.1.59, <0.2.0",
11-
"uipath-runtime>=0.10.0, <0.11.0",
11+
"uipath-runtime>=0.11.0, <0.12.0",
1212
"langgraph>=1.1.8, <2.0.0",
1313
"langchain-core>=1.2.11, <2.0.0",
1414
"langgraph-checkpoint-sqlite>=3.0.3, <4.0.0",

src/uipath_langchain/agent/react/agent.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
from uipath.platform.context_grounding import DeepRagContent
1010
from uipath.platform.guardrails import BaseGuardrail
1111

12+
from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo
13+
from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL
14+
1215
from ...runtime._citations import cas_deep_rag_citation_wrapper
1316
from ..guardrails.actions import GuardrailAction
1417
from ..tools.structured_tool_with_output_type import StructuredToolWithOutputType
@@ -77,7 +80,29 @@ def create_agent(
7780
)
7881
llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools]
7982

80-
init_node = create_init_node(messages, input_schema, config.is_conversational)
83+
# Derive client-side tool schemas from tools for input validation in the init node.
84+
conversational_client_side_tools: dict[str, ClientSideToolInfo] | None = None
85+
if config.is_conversational:
86+
conversational_client_side_tools = {}
87+
for t in agent_tools:
88+
meta = getattr(t, "metadata", None) or {}
89+
if meta.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL):
90+
conversational_client_side_tools[t.name] = {
91+
"input_schema": t.args_schema.model_json_schema()
92+
if hasattr(t, "args_schema")
93+
and t.args_schema
94+
and hasattr(t.args_schema, "model_json_schema")
95+
else None,
96+
"output_schema": meta.get("output_schema"),
97+
}
98+
conversational_client_side_tools = conversational_client_side_tools or None
99+
100+
init_node = create_init_node(
101+
messages,
102+
input_schema,
103+
config.is_conversational,
104+
conversational_client_side_tools,
105+
)
81106

82107
tool_nodes = create_tool_node(agent_tools)
83108

src/uipath_langchain/agent/react/init_node.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
from langgraph.types import Overwrite
77
from pydantic import BaseModel
88

9+
from uipath_langchain.agent.tools.client_side_tool import (
10+
UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY,
11+
ClientSideToolInfo,
12+
apply_tool_filter,
13+
available_client_side_tools,
14+
)
15+
916
from .job_attachments import (
1017
get_job_attachments,
1118
parse_attachments_from_conversation_messages,
@@ -17,6 +24,7 @@ def create_init_node(
1724
| Callable[[Any], Sequence[SystemMessage | HumanMessage]],
1825
input_schema: type[BaseModel] | None,
1926
is_conversational: bool = False,
27+
client_side_tools: dict[str, ClientSideToolInfo] | None = None,
2028
):
2129
def graph_state_init(state: Any) -> Any:
2230
resolved_messages: Sequence[SystemMessage | HumanMessage] | Overwrite
@@ -63,6 +71,21 @@ def graph_state_init(state: Any) -> Any:
6371
)
6472
job_attachments_dict.update(message_attachments)
6573

74+
# Filter available client-side tools based on exchange input declarations
75+
if client_side_tools:
76+
client_tools_input = getattr(
77+
state, UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, None
78+
)
79+
if client_tools_input is None:
80+
available_client_side_tools.set(None)
81+
elif not isinstance(client_tools_input, list):
82+
raise ValueError(
83+
f"'{UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY}' must be a list of tool names, "
84+
f"got {type(client_tools_input).__name__}."
85+
)
86+
else:
87+
apply_tool_filter(client_tools_input, client_side_tools)
88+
6689
# Calculate initial message count for tracking new messages
6790
initial_message_count = (
6891
len(resolved_messages.value)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Factory for creating client-side tools that execute on the client SDK."""
2+
3+
import json
4+
from contextvars import ContextVar
5+
from typing import Annotated, Any, TypedDict
6+
7+
from langchain_core.messages import ToolMessage
8+
from langchain_core.tools import InjectedToolCallId, StructuredTool
9+
from uipath.agent.models.agent import AgentClientSideToolResourceConfig
10+
from uipath.eval.mocks import mockable
11+
12+
from uipath_langchain._utils.durable_interrupt import durable_interrupt
13+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
14+
create_model as create_model_from_schema,
15+
)
16+
from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL
17+
18+
from .utils import sanitize_tool_name
19+
20+
# When set, only tools in this set are available for the current exchange.
21+
# None means all client-side tools are available (default for CAS/web UI).
22+
available_client_side_tools: ContextVar[set[str] | None] = ContextVar(
23+
"available_client_side_tools", default=None
24+
)
25+
26+
UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY = "uipath__client_side_tools"
27+
28+
29+
class ClientSideToolInfo(TypedDict):
30+
input_schema: dict[str, Any] | None
31+
output_schema: dict[str, Any] | None
32+
33+
34+
def apply_tool_filter(
35+
declared_tools: list[str | dict[str, Any]],
36+
agent_tools: dict[str, ClientSideToolInfo],
37+
) -> None:
38+
"""Filter available client-side tools to the intersection of declared and agent tools.
39+
40+
Extracts tool names from the client's declarations, intersects with the agent's
41+
defined client-side tools, and sets the availability filter. Unknown names are
42+
silently ignored.
43+
44+
Args:
45+
declared_tools: List of tool names (strings) or dicts with a 'name' field
46+
from uipath__client_side_tools input.
47+
agent_tools: The agent's client-side tools keyed by name.
48+
"""
49+
declared_names: set[str] = set()
50+
for t in declared_tools:
51+
if isinstance(t, str):
52+
declared_names.add(t)
53+
elif isinstance(t, dict) and "name" in t:
54+
declared_names.add(t["name"])
55+
56+
available_client_side_tools.set(declared_names & set(agent_tools.keys()))
57+
58+
59+
def create_client_side_tool(
60+
resource: AgentClientSideToolResourceConfig,
61+
) -> StructuredTool:
62+
"""Create a client-side tool that pauses the graph and waits for the client to execute it.
63+
64+
The tool uses @durable_interrupt to suspend the graph. The client receives
65+
an executingToolCall event, executes its registered handler, and sends
66+
endToolCall back through CAS.
67+
"""
68+
tool_name = sanitize_tool_name(resource.name)
69+
input_model = create_model_from_schema(resource.input_schema)
70+
71+
async def client_side_tool_fn(
72+
*, tool_call_id: Annotated[str, InjectedToolCallId], **kwargs: Any
73+
) -> Any:
74+
allowed = available_client_side_tools.get()
75+
if allowed is not None and tool_name not in allowed:
76+
return ToolMessage(
77+
content=f"Tool '{tool_name}' is not available — the client has not registered a handler for it.",
78+
tool_call_id=tool_call_id,
79+
status="error",
80+
)
81+
82+
@mockable(
83+
name=resource.name,
84+
description=resource.description,
85+
input_schema=input_model.model_json_schema(),
86+
output_schema=(resource.output_schema or {}),
87+
example_calls=getattr(resource.properties, "example_calls", None),
88+
)
89+
async def execute_tool() -> dict[str, Any]:
90+
"""Execute client-side tool, pausing for client response."""
91+
92+
@durable_interrupt
93+
async def wait_for_client_execution() -> dict[str, Any]:
94+
return {
95+
"tool_call_id": tool_call_id,
96+
"tool_name": tool_name,
97+
"input": kwargs,
98+
}
99+
100+
result = await wait_for_client_execution()
101+
return result if isinstance(result, dict) else {"output": result}
102+
103+
result = await execute_tool()
104+
105+
is_error = result.get("isError", False)
106+
output = result.get("output", result)
107+
108+
if isinstance(output, dict):
109+
try:
110+
content = json.dumps(output)
111+
except TypeError:
112+
content = str(output)
113+
else:
114+
content = str(output) if output is not None else ""
115+
116+
return ToolMessage(
117+
content=content,
118+
tool_call_id=tool_call_id,
119+
status="error" if is_error else "success",
120+
response_metadata={IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True},
121+
)
122+
123+
tool = StructuredTool(
124+
name=tool_name,
125+
description=resource.description or f"Client-side tool: {tool_name}",
126+
args_schema=input_model,
127+
coroutine=client_side_tool_fn,
128+
metadata={
129+
IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True,
130+
"output_schema": resource.output_schema,
131+
},
132+
)
133+
134+
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/agent/tools/tool_node.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
find_latest_ai_message,
2424
)
2525
from uipath_langchain.chat.hitl import (
26+
IS_CONVERSATIONAL_CLIENT_SIDE_TOOL,
2627
REQUIRE_CONVERSATIONAL_CONFIRMATION,
2728
request_conversational_tool_confirmation,
2829
)
@@ -279,10 +280,13 @@ async def _afunc(state: AgentGraphState) -> OutputType:
279280

280281
tool = getattr(tool_node, "tool", None)
281282

282-
# Preserve tool ref so the runtime can discover which tools need confirmation
283-
# (see runtime.py _get_tool_confirmation_info)
283+
# Preserve tool ref so the runtime can discover tool metadata
284+
# (confirmation requirements, client-side markers, etc.)
284285
metadata = getattr(tool, "metadata", None) or {}
285-
if isinstance(tool, BaseTool) and metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
286+
if isinstance(tool, BaseTool) and (
287+
metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION)
288+
or metadata.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL)
289+
):
286290
return RunnableCallableWithTool(
287291
func=_func, afunc=_afunc, name=tool_name, tool=tool
288292
)

src/uipath_langchain/chat/hitl.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
CANCELLED_MESSAGE = "Cancelled by user"
1515
ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments"
1616

17+
IS_CONVERSATIONAL_CLIENT_SIDE_TOOL = "uipath_client_tool"
1718
CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args"
1819
REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation"
1920

0 commit comments

Comments
 (0)