Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions src/uipath_langchain/agent/contracts/static_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""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"
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
19 changes: 19 additions & 0 deletions src/uipath_langchain/agent/tools/static_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,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 +334,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 +358,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_ITEM_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
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