Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/uipath_langchain/agent/react/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
from uipath.platform.context_grounding import DeepRagContent
from uipath.platform.guardrails import BaseGuardrail

from uipath_langchain.agent.tools.client_side_tool import ClientSideToolInfo
from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL

from ...runtime._citations import cas_deep_rag_citation_wrapper
from ..guardrails.actions import GuardrailAction
from ..tools.structured_tool_with_output_type import StructuredToolWithOutputType
Expand Down Expand Up @@ -77,7 +80,24 @@
)
llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools]

init_node = create_init_node(messages, input_schema, config.is_conversational)
# Derive client-side tool schemas from tools for input validation in the init node.
cs_tools: dict[str, ClientSideToolInfo] | None = None
if config.is_conversational:
cs_tools = {}
for t in agent_tools:
meta = getattr(t, "metadata", None) or {}
if meta.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL):
cs_tools[t.name] = {
"input_schema": t.args_schema.model_json_schema()
if hasattr(t, "args_schema") and t.args_schema
else None,
"output_schema": meta.get("output_schema"),
}
cs_tools = cs_tools or None

init_node = create_init_node(
messages, input_schema, config.is_conversational, cs_tools
)

tool_nodes = create_tool_node(agent_tools)

Expand All @@ -101,7 +121,7 @@

terminate_node = create_terminate_node(output_schema, config.is_conversational)

CompleteAgentGraphState = create_state_with_input(

Check warning on line 124 in src/uipath_langchain/agent/react/agent.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "CompleteAgentGraphState" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ47n0y-1JXipUti5fem&open=AZ47n0y-1JXipUti5fem&pullRequest=856
input_schema if input_schema is not None else BaseModel
)

Expand Down
21 changes: 21 additions & 0 deletions src/uipath_langchain/agent/react/init_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
from langgraph.types import Overwrite
from pydantic import BaseModel

from uipath_langchain.agent.tools.client_side_tool import (
UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY,
ClientSideToolInfo,
available_client_side_tools,
validate_and_apply_tool_filter,
)

from .job_attachments import (
get_job_attachments,
parse_attachments_from_conversation_messages,
Expand All @@ -17,6 +24,7 @@ def create_init_node(
| Callable[[Any], Sequence[SystemMessage | HumanMessage]],
input_schema: type[BaseModel] | None,
is_conversational: bool = False,
client_side_tools: dict[str, ClientSideToolInfo] | None = None,
):
def graph_state_init(state: Any) -> Any:
resolved_messages: Sequence[SystemMessage | HumanMessage] | Overwrite
Expand Down Expand Up @@ -63,6 +71,19 @@ def graph_state_init(state: Any) -> Any:
)
job_attachments_dict.update(message_attachments)

# Validate client-side tool declarations from the exchange input
if client_side_tools:
client_tools_input = getattr(state, UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY, None)
if client_tools_input is None:
available_client_side_tools.set(None)
elif not isinstance(client_tools_input, list):
raise ValueError(
f"'{UIPATH_CLIENT_SIDE_TOOLS_INPUT_KEY}' must be a list of tool declarations, "
f"got {type(client_tools_input).__name__}."
)
else:
validate_and_apply_tool_filter(client_tools_input, client_side_tools)

# Calculate initial message count for tracking new messages
initial_message_count = (
len(resolved_messages.value)
Expand Down
174 changes: 174 additions & 0 deletions src/uipath_langchain/agent/tools/client_side_tool.py
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ47n0vn1JXipUti5fek&open=AZ47n0vn1JXipUti5fek&pullRequest=856
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ47n0vn1JXipUti5fei&open=AZ47n0vn1JXipUti5fei&pullRequest=856
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ47n0vn1JXipUti5fej&open=AZ47n0vn1JXipUti5fej&pullRequest=856
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ47n0vn1JXipUti5fel&open=AZ47n0vn1JXipUti5fel&pullRequest=856
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
5 changes: 5 additions & 0 deletions src/uipath_langchain/agent/tools/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from uipath.agent.models.agent import (
AgentClientSideToolResourceConfig,
AgentContextResourceConfig,
AgentEscalationResourceConfig,
AgentIntegrationToolResourceConfig,
Expand All @@ -18,6 +19,7 @@

from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION

from .client_side_tool import create_client_side_tool
from .context_tool import create_context_tool
from .escalation_tool import create_escalation_tool
from .extraction_tool import create_ixp_extraction_tool
Expand Down Expand Up @@ -120,4 +122,7 @@ async def _build_tool_for_resource(
elif isinstance(resource, AgentIxpVsEscalationResourceConfig):
return create_ixp_escalation_tool(resource)

elif isinstance(resource, AgentClientSideToolResourceConfig):
return create_client_side_tool(resource)

return None
10 changes: 7 additions & 3 deletions src/uipath_langchain/agent/tools/tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
find_latest_ai_message,
)
from uipath_langchain.chat.hitl import (
IS_CONVERSATIONAL_CLIENT_SIDE_TOOL,
REQUIRE_CONVERSATIONAL_CONFIRMATION,
request_conversational_tool_confirmation,
)
Expand Down Expand Up @@ -279,10 +280,13 @@ async def _afunc(state: AgentGraphState) -> OutputType:

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

# Preserve tool ref so the runtime can discover which tools need confirmation
# (see runtime.py _get_tool_confirmation_info)
# Preserve tool ref so the runtime can discover tool metadata
# (confirmation requirements, client-side markers, etc.)
metadata = getattr(tool, "metadata", None) or {}
if isinstance(tool, BaseTool) and metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
if isinstance(tool, BaseTool) and (
metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION)
or metadata.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL)
):
return RunnableCallableWithTool(
func=_func, afunc=_afunc, name=tool_name, tool=tool
)
Expand Down
2 changes: 2 additions & 0 deletions src/uipath_langchain/chat/hitl.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
CANCELLED_MESSAGE = "Cancelled by user"
ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments"

IS_CONVERSATIONAL_CLIENT_SIDE_TOOL = "uipath_client_tool"
CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args"
REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation"

Expand Down Expand Up @@ -132,6 +133,7 @@ def ask_confirmation():
"tool_call_id": tool_call_id,
"tool_name": tool.name,
"input": tool_args,
"is_execution_phase": False,
}

response = ask_confirmation()
Expand Down
Loading
Loading