Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.18.51"
version = "0.18.52"
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"
Expand Down
8 changes: 8 additions & 0 deletions src/uipath_langchain/agent/contracts/static_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""The static-args display contract shared by the runtime and the graphs."""

# Run metadata key carrying the display values of static args, keyed by tool
# name, so conversation events show the args the tool is actually called with.
STATIC_ARGS_DISPLAY_METADATA_KEY = "uipath_static_args_display"

# Shown in place of sensitive static arg values.
SENSITIVE_ARG_PLACEHOLDER = "<hidden>"
23 changes: 19 additions & 4 deletions src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
BaseMessage,
ToolCall,
)
from langchain_core.runnables import Runnable
from langchain_core.runnables import Runnable, RunnableConfig, ensure_config
from langchain_core.tools import BaseTool
from pydantic import BaseModel
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
Expand All @@ -22,6 +22,7 @@
from uipath_langchain.chat.handlers.base import ModelPayloadHandler
from uipath_langchain.chat.thinking import thinking_rejects_forced_tool_choice

from ..contracts.static_args import STATIC_ARGS_DISPLAY_METADATA_KEY
from ..exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
Expand Down Expand Up @@ -68,10 +69,12 @@ def _filter_control_flow_tool_calls(


async def _ainvoke(
llm: Runnable[Sequence[AnyMessage], BaseMessage], messages: Sequence[AnyMessage]
llm: Runnable[Sequence[AnyMessage], BaseMessage],
messages: Sequence[AnyMessage],
config: RunnableConfig | None = None,
) -> BaseMessage:
try:
return await llm.ainvoke(messages)
return await llm.ainvoke(messages, config)
except UiPathAPIError as e:
# New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly.
raise_for_provider_http_error(e)
Expand Down Expand Up @@ -191,7 +194,19 @@ async def _invoke_model(
)
llm = call.model.bind_tools(tools, **binding_kwargs)

response = await _ainvoke(llm, call.messages)
# Static args are applied after the model call, but conversation events are
# built from the streamed chunks. Pass the display values along in the run
# metadata so the events show the args the tool is actually called with.
config: RunnableConfig | None = None
if display_static_values := self.static_args_handler.display_static_values:
# An explicit config replaces the inherited metadata, so merge into it.
config = {
"metadata": {
**ensure_config().get("metadata", {}),
STATIC_ARGS_DISPLAY_METADATA_KEY: display_static_values,
}
}
response = await _ainvoke(llm, call.messages, config)
if not isinstance(response, AIMessage):
raise AgentRuntimeError(
code=AgentRuntimeErrorCode.LLM_INVALID_RESPONSE,
Expand Down
24 changes: 21 additions & 3 deletions src/uipath_langchain/agent/tools/static_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from uipath.agent.utils.text_tokens import build_string_from_tokens
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.contracts.static_args import SENSITIVE_ARG_PLACEHOLDER
from uipath_langchain.agent.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
Expand Down Expand Up @@ -53,8 +54,6 @@ class ToolStaticArgument(BaseModel):

_INDEX_AND_REST_REGEX = re.compile(r"^\[(\d+)\](.*)$")

_SENSITIVE_ITEM_PLACEHOLDER = "<hidden>"


def _resolve_argument_properties(
argument_properties: Mapping[str, AgentToolArgumentProperties],
Expand Down Expand Up @@ -154,7 +153,7 @@ def resolve_arraybuilder(
else:
runtime_items.append(resolved.value)
display_value = (
_SENSITIVE_ITEM_PLACEHOLDER
SENSITIVE_ARG_PLACEHOLDER
if resolved.is_sensitive
else resolved.display_value
)
Expand Down Expand Up @@ -312,10 +311,12 @@ class StaticArgsHandler:
"""Resolves and applies static args to tool schemas and tool calls."""

_sanitized_static_values: dict[str, dict[str, Any]] | None
_display_static_values: dict[str, dict[str, Any]] | None
_processed_tools: list[BaseTool] | None

def __init__(self) -> None:
self._sanitized_static_values = None
self._display_static_values = None
self._processed_tools = None

def initialize(
Expand All @@ -332,6 +333,7 @@ def initialize(

self._processed_tools = []
self._sanitized_static_values = {}
self._display_static_values = {}
for tool in tools:
if (
isinstance(tool, ArgumentPropertiesMixin)
Expand All @@ -355,11 +357,27 @@ def initialize(
self._sanitized_static_values[tool.name] = (
sanitize_dict_for_serialization(applied_static_values)
)
self._display_static_values[tool.name] = (
sanitize_dict_for_serialization(
{
path: SENSITIVE_ARG_PLACEHOLDER
if sa.is_sensitive
else sa.display_value
for path, sa in static_args.items()
if path in applied_paths
}
)
)
else:
self._processed_tools.append(tool)

return self._processed_tools

@property
def display_static_values(self) -> dict[str, dict[str, Any]]:
"""Static args per tool name with sensitive values masked, for display."""
return self._display_static_values or {}

def apply_to_response(self, tool_calls: list[ToolCall]) -> None:
"""Applies cached static args to tool calls in-place."""
if not tool_calls or not self._sanitized_static_values:
Expand Down
68 changes: 66 additions & 2 deletions src/uipath_langchain/chat/hitl.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import copy
import functools
import inspect
import json
from inspect import Parameter
from typing import Annotated, Any, Callable, NamedTuple

from jsonpath_ng import parse # type: ignore[import-untyped]
from langchain_core.messages.tool import ToolCall, ToolMessage
from langchain_core.tools import BaseTool, InjectedToolCallId
from langchain_core.tools import tool as langchain_tool
from uipath.core.chat import UiPathConversationToolCallConfirmationEvent

from uipath_langchain._utils.durable_interrupt import durable_interrupt
from uipath_langchain.agent.contracts.static_args import SENSITIVE_ARG_PLACEHOLDER

CANCELLED_MESSAGE = "Cancelled by user"
ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments"
Expand Down Expand Up @@ -147,6 +150,64 @@ def ask_confirmation():
return confirmation.input if confirmation.input is not None else tool_args


def _set_static_arg_paths(
tool: BaseTool,
args: dict[str, Any],
value_for: Callable[[str, Any], Any | None],
) -> dict[str, Any]:
"""Set each static arg path present in ``args`` to ``value_for(path, props)``.

Paths for which ``value_for`` returns None are left as they are.
"""
argument_properties = getattr(tool, "argument_properties", None) or {}
updated = args
for json_path, props in argument_properties.items():
value = value_for(json_path, props)
if value is None:
continue
if updated is args:
updated = copy.deepcopy(args)
parse(json_path).update_or_create(updated, value)
return updated


def _restore_static_args(
tool: BaseTool,
original_args: dict[str, Any],
approved_args: dict[str, Any],
) -> dict[str, Any]:
"""Restore static args from the server-side call args.

Static args are pinned by the agent author, so approval must not change them.
Sensitive ones are only shown masked, and the secret must never follow an
edited arg (e.g. to another host). A static path missing from the call args
is removed rather than taken from the client.
"""
argument_properties = getattr(tool, "argument_properties", None) or {}
restored = copy.deepcopy(approved_args)
for json_path in argument_properties:
# A [*] path sets every item, so restore the whole array
restore_path = json_path[:-3] if json_path.endswith("[*]") else json_path
expr = parse(restore_path)
matches = expr.find(original_args)
if matches:
expr.update_or_create(restored, copy.deepcopy(matches[0].value))
else:
expr.filter(lambda _: True, restored)
return restored


def _mask_sensitive_static_args(tool: BaseTool, args: dict[str, Any]) -> dict[str, Any]:
"""Mask sensitive static args in args that are sent to the model or client."""

def placeholder(json_path: str, props: Any) -> Any | None:
if not getattr(props, "is_sensitive", False):
return None
return SENSITIVE_ARG_PLACEHOLDER if parse(json_path).find(args) else None

return _set_static_arg_paths(tool, args, placeholder)


# for conversational low code agents
def request_conversational_tool_confirmation(
call: ToolCall, tool: BaseTool
Expand All @@ -166,16 +227,19 @@ def request_conversational_tool_confirmation(
tool_call_id=call["id"],
)
cancelled_msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = (
original_args
_mask_sensitive_static_args(tool, original_args)
)
return ConfirmationResult(cancelled=cancelled_msg, args_modified=False)

approved_args = _restore_static_args(tool, original_args, approved_args)

# Mutate call args so the tool executes with the approved values
call["args"] = approved_args
return ConfirmationResult(
cancelled=None,
args_modified=approved_args != original_args,
approved_args=approved_args,
# Sent back to the model and client, so keep sensitive args masked
approved_args=_mask_sensitive_static_args(tool, approved_args),
)
Comment on lines 255 to 262


Expand Down
61 changes: 54 additions & 7 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import copy
import json
import logging
import uuid
Expand Down Expand Up @@ -42,6 +43,9 @@

from uipath_langchain._utils._attachments import render_attachments_block
from uipath_langchain.agent.contracts.client_side_tools import ClientSideToolInfo
from uipath_langchain.agent.contracts.static_args import (
STATIC_ARGS_DISPLAY_METADATA_KEY,
)
from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL

from ._citations import (
Expand Down Expand Up @@ -292,22 +296,28 @@
async def map_event(
self,
message: BaseMessage,
metadata: dict[str, Any] | None = None,
) -> list[UiPathConversationMessageEvent] | None:
"""Convert LangGraph BaseMessage (chunk or full) into a UiPathConversationMessageEvent.

Args:
message: The LangChain message to convert
metadata: The LangGraph stream metadata of the run that produced the message

Returns:
A UiPathConversationMessageEvent if the message should be emitted, None otherwise.
"""
static_args_display = (metadata or {}).get(STATIC_ARGS_DISPLAY_METADATA_KEY)

# --- Streaming AIMessageChunk (check before AIMessage since it's a subclass) ---
if isinstance(message, AIMessageChunk):
return await self.map_ai_message_chunk_to_events(message)
return await self.map_ai_message_chunk_to_events(
message, static_args_display
)

# --- Full AIMessage (e.g. when PII-masking is enabled) ---
if isinstance(message, AIMessage):
return await self.map_ai_message_to_events(message)
return await self.map_ai_message_to_events(message, static_args_display)

# --- ToolMessage ---
if isinstance(message, ToolMessage):
Expand Down Expand Up @@ -358,7 +368,9 @@
return None

async def map_ai_message_chunk_to_events(
self, message: AIMessageChunk
self,
message: AIMessageChunk,
static_args_display: dict[str, dict[str, Any]] | None = None,
) -> list[UiPathConversationMessageEvent]:
if message.id is None: # Should we throw instead?
return []
Expand Down Expand Up @@ -424,15 +436,19 @@
and len(self.current_message.tool_calls) > 0
):
events.extend(
await self.map_current_message_to_start_tool_call_events()
await self.map_current_message_to_start_tool_call_events(
static_args_display
)
)
else:
events.append(self.map_to_message_end_event(message.id))

return events

async def map_ai_message_to_events(
self, message: AIMessage
self,
message: AIMessage,
static_args_display: dict[str, dict[str, Any]] | None = None,
) -> list[UiPathConversationMessageEvent]:
"""Handle a full AIMessage (non-streaming)."""
if message.id is None or message.id in self.seen_message_ids:
Expand All @@ -455,13 +471,41 @@

events.append(self.map_to_content_part_end_event(message.id))
if message.tool_calls:
events.extend(await self.map_current_message_to_start_tool_call_events())
events.extend(
await self.map_current_message_to_start_tool_call_events(
static_args_display
)
)
else:
events.append(self.map_to_message_end_event(message.id))

return events

async def map_current_message_to_start_tool_call_events(self):
def _apply_static_args_display(
self,
tool_call: ToolCall,
static_args_display: dict[str, dict[str, Any]] | None,
) -> ToolCall:
"""Apply static args (resolved after the model call) to a tool call for display."""
# Imported lazily: agent.tools imports the runtime.
from uipath_langchain.agent.tools.static_args import apply_static_args

static_values = (static_args_display or {}).get(tool_call["name"])
if not static_values or not isinstance(tool_call["args"], dict):
return tool_call
try:
args = apply_static_args(static_values, copy.deepcopy(tool_call["args"]))
except Exception as e:
logger.warning(
f"Could not apply static args to tool call {tool_call['id']}: {e}"
)
return tool_call
return cast(ToolCall, {**tool_call, "args": args})

async def map_current_message_to_start_tool_call_events(

Check failure on line 505 in src/uipath_langchain/runtime/messages.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AaGKeAmp704NT43ffpuq&open=AaGKeAmp704NT43ffpuq&pullRequest=1180
self,
static_args_display: dict[str, dict[str, Any]] | None = None,
):
events: list[UiPathConversationMessageEvent] = []
if (
self.current_message
Expand All @@ -484,6 +528,9 @@
tool_call_id_to_message_id_map = {}

for tool_call in self.current_message.tool_calls:
tool_call = self._apply_static_args_display(
tool_call, static_args_display
)
Comment thread
andrewwan-uipath marked this conversation as resolved.
Resolved
tool_call_id = tool_call["id"]
if tool_call_id is not None:
tool_call_id_to_message_id_map[tool_call_id] = (
Expand Down
4 changes: 2 additions & 2 deletions src/uipath_langchain/runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,9 @@ async def stream(
if isinstance(data, tuple) and not self._is_inside_tool_call(
namespace
):
message, _ = data
message, metadata = data
try:
events = await self.chat.map_event(message)
events = await self.chat.map_event(message, metadata)
except Exception as e:
logger.warning(f"Error mapping message event: {e}")
events = None
Expand Down
Loading
Loading