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
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.9.26"
version = "0.9.27"
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
2 changes: 2 additions & 0 deletions src/uipath_langchain/agent/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
create_tools_from_resources,
)
from .tool_node import (
ConversationalToolRunnableCallable,
ToolWrapperMixin,
UiPathToolNode,
create_tool_node,
Expand All @@ -32,6 +33,7 @@
"create_ixp_extraction_tool",
"create_ixp_escalation_tool",
"UiPathToolNode",
"ConversationalToolRunnableCallable",
"ToolWrapperMixin",
"wrap_tools_with_error_handling",
]
23 changes: 22 additions & 1 deletion src/uipath_langchain/agent/tools/tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
extract_current_tool_call_index,
find_latest_ai_message,
)
from uipath_langchain.chat.hitl import request_conversational_tool_confirmation
from uipath_langchain.chat.hitl import (
REQUIRE_CONVERSATIONAL_CONFIRMATION,
request_conversational_tool_confirmation,
)

# the type safety can be improved with generics
ToolWrapperReturnType = dict[str, Any] | Command[Any] | None
Expand Down Expand Up @@ -274,9 +277,27 @@ async def _afunc(state: AgentGraphState) -> OutputType:
raise
return result

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)
metadata = getattr(tool, "metadata", None) or {}
if isinstance(tool, BaseTool) and metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
return ConversationalToolRunnableCallable(
func=_func, afunc=_afunc, name=tool_name, tool=tool
)

return RunnableCallable(func=_func, afunc=_afunc, name=tool_name)


class ConversationalToolRunnableCallable(RunnableCallable):
"""Preserves a reference to the underlying BaseTool for conversational tool confirmation."""

def __init__(self, *, func: Any, afunc: Any, name: str, tool: BaseTool):
super().__init__(func=func, afunc=afunc, name=name)
self.tool = tool


class ToolWrapperMixin:
wrapper: ToolWrapperType | None = None
awrapper: AsyncToolWrapperType | None = None
Expand Down
91 changes: 50 additions & 41 deletions src/uipath_langchain/chat/hitl.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,43 @@
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 (
UiPathConversationToolCallConfirmationValue,
)
from uipath.core.chat import UiPathConversationToolCallConfirmationEvent

from uipath_langchain._utils.durable_interrupt import durable_interrupt

CANCELLED_MESSAGE = "Cancelled by user"
ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments"

CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args"
REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation"


def _wrap_with_args_modified_meta(result: Any, approved_args: dict[str, Any]) -> str:
"""Wrap a tool result with metadata indicating the user modified the args."""
try:
result_value = json.loads(result) if isinstance(result, str) else result
except (json.JSONDecodeError, TypeError):
result_value = result
return json.dumps(
{
"meta": {
"message": ARGS_MODIFIED_MESSAGE,
"executed_args": approved_args,
},
"result": result_value,
}
)


def get_confirmation_schema(tool: Any) -> dict[str, Any] | None:
"""Return the JSON input schema if this tool requires confirmation, else None."""
metadata = getattr(tool, "metadata", None) or {}
if not metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
return None
tool_call_schema = getattr(tool, "tool_call_schema", None)
return tool_call_schema.model_json_schema() if tool_call_schema is not None else {}


class ConfirmationResult(NamedTuple):
"""Result of a tool confirmation check."""

Expand Down Expand Up @@ -47,20 +72,8 @@ def annotate_result(self, output: dict[str, Any] | Any) -> None:
msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = (
self.approved_args
)
if self.args_modified:
try:
result_value = json.loads(msg.content)
except (json.JSONDecodeError, TypeError):
result_value = msg.content
msg.content = json.dumps(
{
"meta": {
"args_modified_by_user": True,
"executed_args": self.approved_args,
},
"result": result_value,
}
)
if self.args_modified and self.approved_args is not None:
msg.content = _wrap_with_args_modified_meta(msg.content, self.approved_args)


def _patch_span_input(approved_args: dict[str, Any]) -> None:
Expand Down Expand Up @@ -113,39 +126,24 @@ def request_approval(
"""
tool_call_id: str = tool_args.pop("tool_call_id")

input_schema: dict[str, Any] = {}
tool_call_schema = getattr(
tool, "tool_call_schema", None
) # doesn't include InjectedToolCallId (tool id from claude/oai/etc.)
if tool_call_schema is not None:
input_schema = tool_call_schema.model_json_schema()

@durable_interrupt
def ask_confirmation():
return UiPathConversationToolCallConfirmationValue(
tool_call_id=tool_call_id,
tool_name=tool.name,
input_schema=input_schema,
input_value=tool_args,
)
return {
"tool_call_id": tool_call_id,
"tool_name": tool.name,
"input": tool_args,
}

response = ask_confirmation()

# The resume payload from CAS has shape:
# {"type": "uipath_cas_tool_call_confirmation",
# "value": {"approved": bool, "input": <edited args | None>}}
if not isinstance(response, dict):
return tool_args

confirmation = response.get("value", response)
if not confirmation.get("approved", True):
confirmation = UiPathConversationToolCallConfirmationEvent.model_validate(response)
if not confirmation.approved:
return None

return (
confirmation.get("input")
if confirmation.get("input") is not None
else tool_args
)
return confirmation.input if confirmation.input is not None else tool_args


# for conversational low code agents
Expand Down Expand Up @@ -200,8 +198,15 @@ def wrapper(**tool_args: Any) -> Any:
if approved_args is None:
return json.dumps({"meta": CANCELLED_MESSAGE})

args_modified = approved_args != tool_args

_patch_span_input(approved_args)
return fn(**approved_args)
result = fn(**approved_args)

if args_modified:
return _wrap_with_args_modified_meta(result, approved_args)

return result

# rewrite the signature: e.g. (query: str) -> (query: str, *, tool_call_id: str)
original_sig = inspect.signature(fn)
Expand Down Expand Up @@ -234,6 +239,10 @@ def wrapper(**tool_args: Any) -> Any:
return_direct=return_direct,
)

if result.metadata is None:
result.metadata = {}
result.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True

_created_tool.append(result)
return result

Expand Down
43 changes: 29 additions & 14 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None
self.runtime_id = runtime_id
self.storage = storage
self.current_message: AIMessageChunk | AIMessage
self.tool_names_requiring_confirmation: set[str] = set()
self.tools_requiring_confirmation: dict[str, Any] = {}
self.seen_message_ids: set[str] = set()
self._storage_lock = asyncio.Lock()
self._citation_stream_processor = CitationStreamProcessor()
Expand Down Expand Up @@ -340,7 +340,13 @@ async def map_ai_message_chunk_to_events(
)
case "tool_call_chunk":
# Accumulate the message chunk. Note that we assume no interweaving of AIMessage and AIMessageChunks for a given message.
if isinstance(self.current_message, AIMessageChunk):
# Skip the first chunk — it's already assigned as current_message above,
# so accumulating it with itself would duplicate fields via string concat
# (e.g. tool name "search_web" becomes "search_websearch_web").
if (
isinstance(self.current_message, AIMessageChunk)
and self.current_message is not message
):
self.current_message = self.current_message + message

elif isinstance(message.content, str) and message.content:
Expand Down Expand Up @@ -425,16 +431,19 @@ async def map_current_message_to_start_tool_call_events(self):
self.current_message.id
)

# if tool requires confirmation, we skip start tool call
if (
tool_call["name"]
not in self.tool_names_requiring_confirmation
):
events.append(
self.map_tool_call_to_tool_call_start_event(
self.current_message.id, tool_call
)
tool_name = tool_call["name"]
require_confirmation = (
tool_name in self.tools_requiring_confirmation
)
input_schema = self.tools_requiring_confirmation.get(tool_name)
events.append(
self.map_tool_call_to_tool_call_start_event(
self.current_message.id,
tool_call,
require_confirmation=require_confirmation or None,
input_schema=input_schema,
)
)

if self.storage is not None:
await self.storage.set_value(
Expand Down Expand Up @@ -531,7 +540,12 @@ async def get_message_id_for_tool_call(
return message_id, is_last

def map_tool_call_to_tool_call_start_event(
self, message_id: str, tool_call: ToolCall
self,
message_id: str,
tool_call: ToolCall,
*,
require_confirmation: bool | None = None,
input_schema: Any | None = None,
) -> UiPathConversationMessageEvent:
return UiPathConversationMessageEvent(
message_id=message_id,
Expand All @@ -541,6 +555,8 @@ def map_tool_call_to_tool_call_start_event(
tool_name=tool_call["name"],
timestamp=self.get_timestamp(),
input=tool_call["args"],
require_confirmation=require_confirmation,
input_schema=input_schema,
),
),
)
Expand Down Expand Up @@ -658,7 +674,7 @@ def _map_langchain_human_message_to_uipath_message_data(
)

return UiPathConversationMessageData(
role="user", content_parts=content_parts, tool_calls=[], interrupts=[]
role="user", content_parts=content_parts, tool_calls=[]
)

@staticmethod
Expand Down Expand Up @@ -708,7 +724,6 @@ def _map_langchain_ai_message_to_uipath_message_data(
role="assistant",
content_parts=content_parts,
tool_calls=uipath_tool_calls,
interrupts=[],
)


Expand Down
43 changes: 30 additions & 13 deletions src/uipath_langchain/runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
)
from uipath.runtime.schema import UiPathRuntimeSchema

from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION
from uipath_langchain.chat.hitl import get_confirmation_schema
from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError
from uipath_langchain.runtime.messages import UiPathChatMessagesMapper
from uipath_langchain.runtime.schema import get_entrypoints_schema, get_graph_schema
Expand Down Expand Up @@ -65,9 +65,7 @@ def __init__(
self.entrypoint: str | None = entrypoint
self.callbacks: list[BaseCallbackHandler] = callbacks or []
self.chat = UiPathChatMessagesMapper(self.runtime_id, storage)
self.chat.tool_names_requiring_confirmation = (
self._get_tool_names_requiring_confirmation()
)
self.chat.tools_requiring_confirmation = self._get_tool_confirmation_info()
self._middleware_node_names: set[str] = self._detect_middleware_nodes()

async def execute(
Expand Down Expand Up @@ -490,17 +488,36 @@ def _detect_middleware_nodes(self) -> set[str]:

return middleware_nodes

def _get_tool_names_requiring_confirmation(self) -> set[str]:
names: set[str] = set()
def _get_tool_confirmation_info(self) -> dict[str, Any]:
"""Build {tool_name: input_schema} for tools requiring confirmation.

Walks compiled graph nodes once at runtime init. This is needed because coded agents
(create_agent) export a compiled graph as the only artifact — there's no side channel
to pass confirmation metadata from the build step to the runtime.
"""
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.

not a problem for low code agents, but might as well unify how we get tools requiring confirmation

schemas: dict[str, Any] = {}
for node_name, node_spec in self.graph.nodes.items():
# langgraph's processing node.bound -> runnable.tool -> baseTool (if tool node)
tool = getattr(getattr(node_spec, "bound", None), "tool", None)
if tool is None:
bound = getattr(node_spec, "bound", None)
if bound is None:
continue
metadata = getattr(tool, "metadata", None) or {}
if metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
names.add(getattr(tool, "name", node_name))
return names

# Coded agents: one tool per node
tool = getattr(bound, "tool", None)
if tool is not None:
schema = get_confirmation_schema(tool)
if schema is not None:
schemas[getattr(tool, "name", node_name)] = schema
continue

# Low-code agents: multiple tools in one node
tools_by_name = getattr(bound, "tools_by_name", None)
if isinstance(tools_by_name, dict):
for name, tool in tools_by_name.items():
schema = get_confirmation_schema(tool)
if schema is not None:
schemas[str(getattr(tool, "name", name))] = schema

return schemas

def _is_middleware_node(self, node_name: str) -> bool:
"""Check if a node name represents a middleware node."""
Expand Down
Loading
Loading