-
Notifications
You must be signed in to change notification settings - Fork 34
feat: add client side tools to mapper and runtime [JAR-9629] #856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6cbcef2
8913bd6
32ede01
79b1200
471ea87
542553a
f1a1cf7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| """Factory for creating client-side tools that execute on the client SDK.""" | ||
|
|
||
| import json | ||
| from contextvars import ContextVar | ||
| from typing import Annotated, Any, TypedDict | ||
|
|
||
| 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 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( | ||
|
Check failure on line 34 in src/uipath_langchain/agent/tools/client_side_tool.py
|
||
| 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: 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( | ||
|
Check warning on line 84 in src/uipath_langchain/agent/tools/client_side_tool.py
|
||
| 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( | ||
|
Check warning on line 91 in src/uipath_langchain/agent/tools/client_side_tool.py
|
||
| 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( | ||
|
Check failure on line 101 in src/uipath_langchain/agent/tools/client_side_tool.py
|
||
| 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", | ||
| ) | ||
|
|
||
|
Comment on lines
+34
to
+124
|
||
| @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), | ||
| ) | ||
| 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, | ||
| response_metadata={IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True}, | ||
| ) | ||
|
|
||
| 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={ | ||
| IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True, | ||
| "output_schema": resource.output_schema, | ||
| }, | ||
| ) | ||
|
|
||
| return tool | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, didn't we agree to the following (based on Slack discussions / this comment)?
Given:
uipath__client_side_toolsnamesWe do:
uipath__client_side_toolspassed in (it is None/null), then agent assumes to use all of their client-side-toolsuipath__client_side_toolsis an array of names, then filter out the agent's tools to only be the names inuipath__client_side_tools. Ifuipath__client_side_toolscontains irrelevant/wrong names, that's okay for now I think.So for example if agent has definition with tools: [A, B]
uipath__client_side_tools: Agent has access to [A, B]Also,
validate_and_apply_tool_filterseems to assume theuipath__client_side_toolsis a list of tool-definitions rather than a list of strings, which you're passing from CAS?