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
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Durable interrupt package for side-effect-safe interrupt/resume in LangGraph."""

from .decorator import _durable_state, durable_interrupt
from .decorator import (
_durable_state,
durable_interrupt,
)
from .skip_interrupt import SkipInterruptValue

__all__ = [
Expand Down
2 changes: 1 addition & 1 deletion src/uipath_langchain/agent/tools/context_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain._utils import get_execution_folder_path
from uipath_langchain._utils.durable_interrupt import durable_interrupt
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
create_model as create_model_from_schema,
Expand All @@ -40,7 +41,6 @@
)
from uipath_langchain.retrievers import ContextGroundingRetriever

from .durable_interrupt import durable_interrupt
from .structured_tool_with_argument_properties import (
StructuredToolWithArgumentProperties,
)
Expand Down
2 changes: 1 addition & 1 deletion src/uipath_langchain/agent/tools/escalation_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain._utils import get_execution_folder_path
from uipath_langchain._utils.durable_interrupt import durable_interrupt
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.tools.static_args import (
handle_static_args,
Expand All @@ -31,7 +32,6 @@

from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from ..react.types import AgentGraphState
from .durable_interrupt import durable_interrupt
from .tool_node import ToolWrapperReturnType
from .utils import (
resolve_task_title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@
)
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.react.types import AgentGraphState
from uipath_langchain.agent.tools.durable_interrupt import (
from uipath_langchain._utils.durable_interrupt import (
SkipInterruptValue,
durable_interrupt,
)
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.react.types import AgentGraphState
from uipath_langchain.agent.tools.internal_tools.schema_utils import (
BATCH_TRANSFORM_OUTPUT_SCHEMA,
add_query_field_to_schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@
)
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.react.types import AgentGraphState
from uipath_langchain.agent.tools.durable_interrupt import (
from uipath_langchain._utils.durable_interrupt import (
SkipInterruptValue,
durable_interrupt,
)
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.react.types import AgentGraphState
from uipath_langchain.agent.tools.internal_tools.schema_utils import (
add_query_field_to_schema,
)
Expand Down
2 changes: 1 addition & 1 deletion src/uipath_langchain/agent/tools/ixp_escalation_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
)
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain._utils.durable_interrupt import durable_interrupt
from uipath_langchain.agent.react.types import AgentGraphState
from uipath_langchain.agent.tools.tool_node import (
ToolWrapperMixin,
ToolWrapperReturnType,
)

from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode
from .durable_interrupt import durable_interrupt
from .structured_tool_with_output_type import StructuredToolWithOutputType
from .utils import (
resolve_task_title,
Expand Down
2 changes: 1 addition & 1 deletion src/uipath_langchain/agent/tools/process_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from uipath.platform.orchestrator import JobState

from uipath_langchain._utils import get_execution_folder_path
from uipath_langchain._utils.durable_interrupt import durable_interrupt
from uipath_langchain.agent.react.job_attachments import get_job_attachments
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
from uipath_langchain.agent.react.types import AgentGraphState
Expand All @@ -24,7 +25,6 @@
ToolWrapperReturnType,
)

from .durable_interrupt import durable_interrupt
from .utils import sanitize_tool_name


Expand Down
11 changes: 11 additions & 0 deletions src/uipath_langchain/agent/tools/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
LowCodeAgentDefinition,
)

from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION

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 @@ -54,6 +56,15 @@ async def create_tools_from_resources(
else:
tools.append(tool)

if agent.is_conversational:
props = getattr(resource, "properties", None)
if props and getattr(
props, REQUIRE_CONVERSATIONAL_CONFIRMATION, False
):
if tool.metadata is None:
tool.metadata = {}
tool.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True

return tools


Expand Down
32 changes: 30 additions & 2 deletions src/uipath_langchain/agent/tools/tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
extract_current_tool_call_index,
find_latest_ai_message,
)
from uipath_langchain.chat.hitl import request_conversational_tool_confirmation

# the type safety can be improved with generics
ToolWrapperReturnType = dict[str, Any] | Command[Any] | None
Expand Down Expand Up @@ -80,6 +81,15 @@ def _func(self, state: AgentGraphState) -> OutputType:
if call is None:
return None

# prompt user for approval if tool requires confirmation
conversational_confirmation = request_conversational_tool_confirmation(
call, self.tool
)
if conversational_confirmation:
if conversational_confirmation.cancelled:
# tool confirmation rejected
return self._process_result(call, conversational_confirmation.cancelled)

try:
if self.wrapper:
inputs = self._prepare_wrapper_inputs(
Expand All @@ -88,7 +98,11 @@ def _func(self, state: AgentGraphState) -> OutputType:
result = self.wrapper(*inputs)
else:
result = self.tool.invoke(call)
return self._process_result(call, result)
output = self._process_result(call, result)
if conversational_confirmation:
# HITL approved - apply confirmation metadata to tool result message
conversational_confirmation.annotate_result(output)
return output
except GraphBubbleUp:
# LangGraph uses exceptions for interrupt control flow — re-raise so
# handle_tool_errors doesn't swallow expected interrupts as errors.
Expand All @@ -104,15 +118,29 @@ async def _afunc(self, state: AgentGraphState) -> OutputType:
if call is None:
return None

# prompt user for approval if tool requires confirmation
conversational_confirmation = request_conversational_tool_confirmation(
call, self.tool
)
if conversational_confirmation:
if conversational_confirmation.cancelled:
# tool confirmation rejected
return self._process_result(call, conversational_confirmation.cancelled)

try:
if self.awrapper:
inputs = self._prepare_wrapper_inputs(
self.awrapper, self.tool, call, state
)

result = await self.awrapper(*inputs)
else:
result = await self.tool.ainvoke(call)
return self._process_result(call, result)
output = self._process_result(call, result)
if conversational_confirmation:
# HITL approved - apply confirmation metadata to tool result message
conversational_confirmation.annotate_result(output)
return output
except GraphBubbleUp:
# LangGraph uses exceptions for interrupt control flow — re-raise so
# handle_tool_errors doesn't swallow expected interrupts as errors.
Expand Down
110 changes: 100 additions & 10 deletions src/uipath_langchain/chat/hitl.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,66 @@
import functools
import inspect
import json
from inspect import Parameter
from typing import Annotated, Any, Callable
from typing import Annotated, Any, Callable, NamedTuple

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 langgraph.types import interrupt
from uipath.core.chat import (
UiPathConversationToolCallConfirmationValue,
)

_CANCELLED_MESSAGE = "Cancelled by user"
from uipath_langchain._utils.durable_interrupt import durable_interrupt

CANCELLED_MESSAGE = "Cancelled by user"

CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args"
REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation"


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

cancelled: ToolMessage | None # ToolMessage if cancelled, None if approved
args_modified: bool
approved_args: dict[str, Any] | None = None

def annotate_result(self, output: dict[str, Any] | Any) -> None:
"""Apply confirmation metadata to a tool result message."""
msg = None
if isinstance(output, dict):
messages = output.get("messages")
if messages:
msg = messages[0]
else:
# Tools with @durable_interrupt return a Command whose messages
# are nested under output.update["messages"].
update = getattr(output, "update", None)
if isinstance(update, dict):
messages = update.get("messages")
if messages:
msg = messages[0]
if msg is None:
return
if self.approved_args is not 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,
}
)


def _patch_span_input(approved_args: dict[str, Any]) -> None:
Expand Down Expand Up @@ -53,7 +103,7 @@ def _patch_span_input(approved_args: dict[str, Any]) -> None:
pass


def _request_approval(
def request_approval(
tool_args: dict[str, Any],
tool: BaseTool,
) -> dict[str, Any] | None:
Expand All @@ -70,14 +120,16 @@ def _request_approval(
if tool_call_schema is not None:
input_schema = tool_call_schema.model_json_schema()

response = interrupt(
UiPathConversationToolCallConfirmationValue(
@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,
)
)

response = ask_confirmation()

# The resume payload from CAS has shape:
# {"type": "uipath_cas_tool_call_confirmation",
Expand All @@ -89,9 +141,46 @@ def _request_approval(
if not confirmation.get("approved", True):
return None

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


# for conversational low code agents
def request_conversational_tool_confirmation(
call: ToolCall, tool: BaseTool
) -> ConfirmationResult | None:
"""Check whether a tool requires user confirmation and request approval"""
if not (tool.metadata and tool.metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION)):
return None

original_args = call["args"]
approved_args = request_approval(
{**original_args, "tool_call_id": call["id"]}, tool
)
if approved_args is None:
cancelled_msg = ToolMessage(
content=json.dumps({"meta": CANCELLED_MESSAGE}),
name=call["name"],
tool_call_id=call["id"],
)
cancelled_msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = (
original_args
)
return ConfirmationResult(cancelled=cancelled_msg, args_modified=False)

# 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,
)


# for conversational coded agents
def requires_approval(
func: Callable[..., Any] | None = None,
*,
Expand All @@ -107,9 +196,10 @@ def decorator(fn: Callable[..., Any]) -> BaseTool:
# wrap the tool/function
@functools.wraps(fn)
def wrapper(**tool_args: Any) -> Any:
approved_args = _request_approval(tool_args, _created_tool[0])
approved_args = request_approval(tool_args, _created_tool[0])
if approved_args is None:
return _CANCELLED_MESSAGE
return json.dumps({"meta": CANCELLED_MESSAGE})

_patch_span_input(approved_args)
return fn(**approved_args)

Expand Down
Loading
Loading