Skip to content
Merged
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
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[project]
name = "uipath-langchain"
version = "0.11.10"
version = "0.11.11"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
"uipath>=2.10.72, <2.11.0",
"uipath-core>=0.5.15, <0.6.0",
"uipath>=2.10.74, <2.11.0",
"uipath-core>=0.5.17, <0.6.0",
"uipath-platform>=0.1.59, <0.2.0",
"uipath-runtime>=0.10.0, <0.11.0",
"uipath-runtime>=0.11.0, <0.12.0",
"langgraph>=1.1.8, <2.0.0",
"langchain-core>=1.2.11, <2.0.0",
"langgraph-checkpoint-sqlite>=3.0.3, <4.0.0",
Expand Down
27 changes: 26 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,29 @@
)
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.
conversational_client_side_tools: dict[str, ClientSideToolInfo] | None = None
if config.is_conversational:
conversational_client_side_tools = {}
for t in agent_tools:
meta = getattr(t, "metadata", None) or {}
if meta.get(IS_CONVERSATIONAL_CLIENT_SIDE_TOOL):
conversational_client_side_tools[t.name] = {
"input_schema": t.args_schema.model_json_schema()
if hasattr(t, "args_schema")
and t.args_schema
and hasattr(t.args_schema, "model_json_schema")
else None,
"output_schema": meta.get("output_schema"),
Comment on lines +91 to +96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just a question - how come input_schema is from t.args_schema and output_schema is from meta?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The StructuredTool class in LangChain has a built-in args_schema field for defining what inputs a tool accepts. There's no equivalent built-in field for output schema. So when creating client-side tools, the input schema goes into args_schema (LangChain's standard field) and the output schema gets stashed in the metadata dict as a custom key. That's why in agent.py, input_schema is read from t.args_schema and output_schema is read from meta.get("output_schema") — they live in different places because LangChain only has first-class support for the input side.

}
conversational_client_side_tools = conversational_client_side_tools or None

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

tool_nodes = create_tool_node(agent_tools)

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

terminate_node = create_terminate_node(output_schema, config.is_conversational)

CompleteAgentGraphState = create_state_with_input(

Check warning on line 129 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
23 changes: 23 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,
apply_tool_filter,
available_client_side_tools,
)

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,21 @@ def graph_state_init(state: Any) -> Any:
)
job_attachments_dict.update(message_attachments)

# Filter available client-side tools based on exchange input declarations
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 names, "
f"got {type(client_tools_input).__name__}."
)
else:
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
134 changes: 134 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,134 @@
"""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 apply_tool_filter(
declared_tools: list[str | dict[str, Any]],
agent_tools: dict[str, ClientSideToolInfo],
) -> None:
"""Filter available client-side tools to the intersection of declared and agent tools.

Extracts tool names from the client's declarations, intersects with the agent's
defined client-side tools, and sets the availability filter. Unknown names are
silently ignored.

Args:
declared_tools: List of tool names (strings) or dicts with a 'name' field
from uipath__client_side_tools input.
agent_tools: The agent's client-side tools keyed by name.
"""
declared_names: set[str] = set()
for t in declared_tools:
if isinstance(t, str):
declared_names.add(t)
elif isinstance(t, dict) and "name" in t:
declared_names.add(t["name"])

available_client_side_tools.set(declared_names & set(agent_tools.keys()))


def create_client_side_tool(

Check failure on line 59 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 19 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 receives
an executingToolCall event, executes its registered handler, and sends
endToolCall back through CAS.
"""
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",
)

@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,
}

result = await wait_for_client_execution()
return result if isinstance(result, dict) else {"output": result}

result = await execute_tool()
Comment thread
norman-le marked this conversation as resolved.

is_error = result.get("isError", False)
output = result.get("output", result)

if isinstance(output, dict):
try:
content = json.dumps(output)
except TypeError:
content = str(output)
else:
content = str(output) if output is not None else ""

return ToolMessage(
content=content,
tool_call_id=tool_call_id,
status="error" if is_error else "success",
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
1 change: 1 addition & 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
Loading
Loading