Skip to content

Commit 74784ac

Browse files
authored
feat: confirmToolCall support — startToolCall with requireConfirmation + ToolRunnableCallable [JAR-9208] (#703)
1 parent 03d593c commit 74784ac

11 files changed

Lines changed: 288 additions & 151 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.10.7"
3+
version = "0.10.8"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath>=2.10.53, <2.11.0",
9-
"uipath-core>=0.5.2, <0.6.0",
8+
"uipath>=2.10.57, <2.11.0",
9+
"uipath-core>=0.5.13, <0.6.0",
1010
"uipath-platform>=0.1.36, <0.2.0",
1111
"uipath-runtime>=0.10.0, <0.11.0",
1212
"langgraph>=1.1.8, <2.0.0",

src/uipath_langchain/agent/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
create_tools_from_resources,
1313
)
1414
from .tool_node import (
15+
RunnableCallableWithTool,
1516
ToolWrapperMixin,
1617
UiPathToolNode,
1718
create_tool_node,
@@ -32,6 +33,7 @@
3233
"create_ixp_extraction_tool",
3334
"create_ixp_escalation_tool",
3435
"UiPathToolNode",
36+
"RunnableCallableWithTool",
3537
"ToolWrapperMixin",
3638
"wrap_tools_with_error_handling",
3739
]

src/uipath_langchain/agent/tools/tool_node.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
extract_current_tool_call_index,
2323
find_latest_ai_message,
2424
)
25-
from uipath_langchain.chat.hitl import request_conversational_tool_confirmation
25+
from uipath_langchain.chat.hitl import (
26+
REQUIRE_CONVERSATIONAL_CONFIRMATION,
27+
request_conversational_tool_confirmation,
28+
)
2629

2730
# the type safety can be improved with generics
2831
ToolWrapperReturnType = dict[str, Any] | Command[Any] | None
@@ -274,9 +277,34 @@ async def _afunc(state: AgentGraphState) -> OutputType:
274277
raise
275278
return result
276279

280+
tool = getattr(tool_node, "tool", None)
281+
282+
# Preserve tool ref so the runtime can discover which tools need confirmation
283+
# (see runtime.py _get_tool_confirmation_info)
284+
metadata = getattr(tool, "metadata", None) or {}
285+
if isinstance(tool, BaseTool) and metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
286+
return RunnableCallableWithTool(
287+
func=_func, afunc=_afunc, name=tool_name, tool=tool
288+
)
289+
277290
return RunnableCallable(func=_func, afunc=_afunc, name=tool_name)
278291

279292

293+
class RunnableCallableWithTool(RunnableCallable):
294+
"""A RunnableCallable that preserves a reference to its underlying BaseTool."""
295+
296+
def __init__(
297+
self,
298+
*,
299+
func: Callable[..., Any] | None,
300+
afunc: Callable[..., Awaitable[Any]] | None,
301+
name: str,
302+
tool: BaseTool,
303+
):
304+
super().__init__(func=func, afunc=afunc, name=name)
305+
self.tool = tool
306+
307+
280308
class ToolWrapperMixin:
281309
wrapper: ToolWrapperType | None = None
282310
awrapper: AsyncToolWrapperType | None = None

src/uipath_langchain/chat/hitl.py

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,43 @@
77
from langchain_core.messages.tool import ToolCall, ToolMessage
88
from langchain_core.tools import BaseTool, InjectedToolCallId
99
from langchain_core.tools import tool as langchain_tool
10-
from uipath.core.chat import (
11-
UiPathConversationToolCallConfirmationValue,
12-
)
10+
from uipath.core.chat import UiPathConversationToolCallConfirmationEvent
1311

1412
from uipath_langchain._utils.durable_interrupt import durable_interrupt
1513

1614
CANCELLED_MESSAGE = "Cancelled by user"
15+
ARGS_MODIFIED_MESSAGE = "User has modified the tool arguments"
1716

1817
CONVERSATIONAL_APPROVED_TOOL_ARGS = "conversational_approved_tool_args"
1918
REQUIRE_CONVERSATIONAL_CONFIRMATION = "require_conversational_confirmation"
2019

2120

21+
def _wrap_with_args_modified_meta(result: Any, approved_args: dict[str, Any]) -> str:
22+
"""Wrap a tool result with metadata indicating the user modified the args."""
23+
try:
24+
result_value = json.loads(result) if isinstance(result, str) else result
25+
except (json.JSONDecodeError, TypeError):
26+
result_value = result
27+
return json.dumps(
28+
{
29+
"meta": {
30+
"message": ARGS_MODIFIED_MESSAGE,
31+
"executed_args": approved_args,
32+
},
33+
"result": result_value,
34+
}
35+
)
36+
37+
38+
def get_confirmation_schema(tool: Any) -> dict[str, Any] | None:
39+
"""Return the JSON input schema if this tool requires confirmation, else None."""
40+
metadata = getattr(tool, "metadata", None) or {}
41+
if not metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
42+
return None
43+
tool_call_schema = getattr(tool, "tool_call_schema", None)
44+
return tool_call_schema.model_json_schema() if tool_call_schema is not None else {}
45+
46+
2247
class ConfirmationResult(NamedTuple):
2348
"""Result of a tool confirmation check."""
2449

@@ -47,20 +72,8 @@ def annotate_result(self, output: dict[str, Any] | Any) -> None:
4772
msg.response_metadata[CONVERSATIONAL_APPROVED_TOOL_ARGS] = (
4873
self.approved_args
4974
)
50-
if self.args_modified:
51-
try:
52-
result_value = json.loads(msg.content)
53-
except (json.JSONDecodeError, TypeError):
54-
result_value = msg.content
55-
msg.content = json.dumps(
56-
{
57-
"meta": {
58-
"args_modified_by_user": True,
59-
"executed_args": self.approved_args,
60-
},
61-
"result": result_value,
62-
}
63-
)
75+
if self.args_modified and self.approved_args is not None:
76+
msg.content = _wrap_with_args_modified_meta(msg.content, self.approved_args)
6477

6578

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

116-
input_schema: dict[str, Any] = {}
117-
tool_call_schema = getattr(
118-
tool, "tool_call_schema", None
119-
) # doesn't include InjectedToolCallId (tool id from claude/oai/etc.)
120-
if tool_call_schema is not None:
121-
input_schema = tool_call_schema.model_json_schema()
122-
123129
@durable_interrupt
124130
def ask_confirmation():
125-
return UiPathConversationToolCallConfirmationValue(
126-
tool_call_id=tool_call_id,
127-
tool_name=tool.name,
128-
input_schema=input_schema,
129-
input_value=tool_args,
130-
)
131+
return {
132+
"tool_call_id": tool_call_id,
133+
"tool_name": tool.name,
134+
"input": tool_args,
135+
}
131136

132137
response = ask_confirmation()
133138

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

140-
confirmation = response.get("value", response)
141-
if not confirmation.get("approved", True):
142+
confirmation = UiPathConversationToolCallConfirmationEvent.model_validate(response)
143+
if not confirmation.approved:
142144
return None
143145

144-
return (
145-
confirmation.get("input")
146-
if confirmation.get("input") is not None
147-
else tool_args
148-
)
146+
return confirmation.input if confirmation.input is not None else tool_args
149147

150148

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

201+
args_modified = approved_args != tool_args
202+
203203
_patch_span_input(approved_args)
204-
return fn(**approved_args)
204+
result = fn(**approved_args)
205+
206+
if args_modified:
207+
return _wrap_with_args_modified_meta(result, approved_args)
208+
209+
return result
205210

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

242+
if result.metadata is None:
243+
result.metadata = {}
244+
result.metadata[REQUIRE_CONVERSATIONAL_CONFIRMATION] = True
245+
237246
_created_tool.append(result)
238247
return result
239248

src/uipath_langchain/runtime/messages.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None
5959
self.runtime_id = runtime_id
6060
self.storage = storage
6161
self.current_message: AIMessageChunk | AIMessage
62-
self.tool_names_requiring_confirmation: set[str] = set()
62+
self.tools_requiring_confirmation: dict[str, Any] = {}
6363
self.seen_message_ids: set[str] = set()
6464
self._storage_lock = asyncio.Lock()
6565
self._citation_stream_processor = CitationStreamProcessor()
@@ -339,8 +339,12 @@ async def map_ai_message_chunk_to_events(
339339
self._chunk_to_message_event(message.id, chunk)
340340
)
341341
case "tool_call_chunk":
342-
# Accumulate the message chunk. Note that we assume no interweaving of AIMessage and AIMessageChunks for a given message.
343-
if isinstance(self.current_message, AIMessageChunk):
342+
# Skip self-merge: OpenAI's chunk #1 carries the tool name, and adding
343+
# a chunk to itself doubles string fields ("search_web" -> "search_websearch_web").
344+
if (
345+
isinstance(self.current_message, AIMessageChunk)
346+
and self.current_message is not message
347+
):
344348
self.current_message = self.current_message + message
345349

346350
elif isinstance(message.content, str) and message.content:
@@ -427,16 +431,19 @@ async def map_current_message_to_start_tool_call_events(self):
427431
self.current_message.id
428432
)
429433

430-
# if tool requires confirmation, we skip start tool call
431-
if (
432-
tool_call["name"]
433-
not in self.tool_names_requiring_confirmation
434-
):
435-
events.append(
436-
self.map_tool_call_to_tool_call_start_event(
437-
self.current_message.id, tool_call
438-
)
434+
tool_name = tool_call["name"]
435+
require_confirmation = (
436+
tool_name in self.tools_requiring_confirmation
437+
)
438+
input_schema = self.tools_requiring_confirmation.get(tool_name)
439+
events.append(
440+
self.map_tool_call_to_tool_call_start_event(
441+
self.current_message.id,
442+
tool_call,
443+
require_confirmation=require_confirmation or None,
444+
input_schema=input_schema,
439445
)
446+
)
440447

441448
if self.storage is not None:
442449
await self.storage.set_value(
@@ -533,7 +540,12 @@ async def get_message_id_for_tool_call(
533540
return message_id, is_last
534541

535542
def map_tool_call_to_tool_call_start_event(
536-
self, message_id: str, tool_call: ToolCall
543+
self,
544+
message_id: str,
545+
tool_call: ToolCall,
546+
*,
547+
require_confirmation: bool | None = None,
548+
input_schema: Any | None = None,
537549
) -> UiPathConversationMessageEvent:
538550
return UiPathConversationMessageEvent(
539551
message_id=message_id,
@@ -543,6 +555,8 @@ def map_tool_call_to_tool_call_start_event(
543555
tool_name=tool_call["name"],
544556
timestamp=self.get_timestamp(),
545557
input=tool_call["args"],
558+
require_confirmation=require_confirmation,
559+
input_schema=input_schema,
546560
),
547561
),
548562
)
@@ -667,7 +681,7 @@ def _map_langchain_human_message_to_uipath_message_data(
667681
)
668682

669683
return UiPathConversationMessageData(
670-
role="user", content_parts=content_parts, tool_calls=[], interrupts=[]
684+
role="user", content_parts=content_parts, tool_calls=[]
671685
)
672686

673687
@staticmethod
@@ -717,7 +731,6 @@ def _map_langchain_ai_message_to_uipath_message_data(
717731
role="assistant",
718732
content_parts=content_parts,
719733
tool_calls=uipath_tool_calls,
720-
interrupts=[],
721734
)
722735

723736

src/uipath_langchain/runtime/runtime.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from langchain_core.callbacks import BaseCallbackHandler
77
from langchain_core.runnables.config import RunnableConfig
8+
from langchain_core.tools import BaseTool
89
from langgraph.errors import EmptyInputError, GraphRecursionError, InvalidUpdateError
910
from langgraph.graph.state import CompiledStateGraph
1011
from langgraph.types import Command, Interrupt, StateSnapshot
@@ -29,7 +30,8 @@
2930
)
3031
from uipath.runtime.schema import UiPathRuntimeSchema
3132

32-
from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION
33+
from uipath_langchain.agent.tools.tool_node import RunnableCallableWithTool
34+
from uipath_langchain.chat.hitl import get_confirmation_schema
3335
from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError
3436
from uipath_langchain.runtime.messages import UiPathChatMessagesMapper
3537
from uipath_langchain.runtime.schema import get_entrypoints_schema, get_graph_schema
@@ -65,9 +67,7 @@ def __init__(
6567
self.entrypoint: str | None = entrypoint
6668
self.callbacks: list[BaseCallbackHandler] = callbacks or []
6769
self.chat = UiPathChatMessagesMapper(self.runtime_id, storage)
68-
self.chat.tool_names_requiring_confirmation = (
69-
self._get_tool_names_requiring_confirmation()
70-
)
70+
self.chat.tools_requiring_confirmation = self._get_tool_confirmation_info()
7171
self._middleware_node_names: set[str] = self._detect_middleware_nodes()
7272

7373
async def execute(
@@ -490,17 +490,37 @@ def _detect_middleware_nodes(self) -> set[str]:
490490

491491
return middleware_nodes
492492

493-
def _get_tool_names_requiring_confirmation(self) -> set[str]:
494-
names: set[str] = set()
495-
for node_name, node_spec in self.graph.nodes.items():
496-
# langgraph's processing node.bound -> runnable.tool -> baseTool (if tool node)
497-
tool = getattr(getattr(node_spec, "bound", None), "tool", None)
498-
if tool is None:
493+
def _get_tool_confirmation_info(self) -> dict[str, Any]:
494+
"""Build {tool_name: input_schema} for tools requiring confirmation.
495+
496+
Walks compiled graph nodes once at runtime init. This is needed because coded agents
497+
(create_agent) export a compiled graph as the only artifact — there's no side channel
498+
to pass confirmation metadata from the build step to the runtime.
499+
"""
500+
schemas: dict[str, Any] = {}
501+
for node_spec in self.graph.nodes.values():
502+
bound = getattr(node_spec, "bound", None)
503+
if bound is None:
504+
continue
505+
506+
# Coded agents: one tool per node
507+
if isinstance(bound, RunnableCallableWithTool):
508+
schema = get_confirmation_schema(bound.tool)
509+
if schema is not None:
510+
schemas[bound.tool.name] = schema
499511
continue
500-
metadata = getattr(tool, "metadata", None) or {}
501-
if metadata.get(REQUIRE_CONVERSATIONAL_CONFIRMATION):
502-
names.add(getattr(tool, "name", node_name))
503-
return names
512+
513+
# Low-code agents: multiple tools in one node
514+
tools_by_name = getattr(bound, "tools_by_name", None)
515+
if isinstance(tools_by_name, dict):
516+
for tool in tools_by_name.values():
517+
if not isinstance(tool, BaseTool):
518+
continue
519+
schema = get_confirmation_schema(tool)
520+
if schema is not None:
521+
schemas[tool.name] = schema
522+
523+
return schemas
504524

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

0 commit comments

Comments
 (0)