Skip to content

Commit 7760d64

Browse files
authored
Merge pull request #115 from UiPath/feat/jar-9629-executing-tool-call-event-after-confirmation
feat: emit new executing tool call event after any tool confirmation
2 parents aac68e1 + 303b206 commit 7760d64

5 files changed

Lines changed: 212 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.10.4"
3+
version = "0.10.5"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/chat/protocol.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ async def emit_interrupt_event(
4343
"""
4444
...
4545

46+
async def emit_executing_tool_call_event(
47+
self,
48+
tool_call_id: str,
49+
tool_input: dict[str, Any] | None = None,
50+
) -> None:
51+
"""Emit an executingToolCall event.
52+
53+
Called after a tool-call confirmation resumes to signal that the tool
54+
is about to execute with the final (possibly modified) input.
55+
56+
Args:
57+
tool_call_id: The tool call ID from the interrupt request.
58+
tool_input: The final tool input after confirmation.
59+
"""
60+
...
61+
4662
async def emit_exchange_end_event(self) -> None:
4763
"""Send an exchange end event."""
4864
...

src/uipath/runtime/chat/runtime.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
from typing import Any, AsyncGenerator, cast
55

6+
from pydantic import ValidationError
7+
from uipath.core.chat import UiPathConversationToolCallConfirmationEvent
68
from uipath.core.triggers import UiPathResumeTriggerType
79

810
from uipath.runtime.base import (
@@ -108,6 +110,34 @@ async def stream(
108110
await self.chat_bridge.wait_for_resume()
109111
)
110112

113+
# If this was a tool-call confirmation,
114+
# emit executingToolCall with the final input.
115+
# This allows client side tools to run after any tool confirmation.
116+
confirmation = _parse_confirmation(resume_data)
117+
if confirmation and confirmation.approved:
118+
assert trigger.api_resume is not None, (
119+
"Confirmed trigger must have api_resume"
120+
)
121+
request = trigger.api_resume.request
122+
assert isinstance(request, dict), (
123+
"Confirmed trigger api_resume.request must be a dict"
124+
)
125+
126+
tool_call_id = request.get("tool_call_id")
127+
assert tool_call_id is not None, (
128+
"Confirmed trigger request must contain tool_call_id"
129+
)
130+
131+
confirmed_input = (
132+
confirmation.input
133+
if confirmation.input is not None
134+
else request.get("input")
135+
)
136+
await self.chat_bridge.emit_executing_tool_call_event(
137+
tool_call_id=tool_call_id,
138+
tool_input=confirmed_input,
139+
)
140+
111141
assert trigger.interrupt_id is not None, (
112142
"Trigger interrupt_id cannot be None"
113143
)
@@ -162,3 +192,16 @@ async def dispose(self) -> None:
162192
await self.chat_bridge.disconnect()
163193
except Exception as e:
164194
logger.warning(f"Error disconnecting chat bridge: {e}")
195+
196+
197+
def _parse_confirmation(
198+
data: dict[str, Any],
199+
) -> UiPathConversationToolCallConfirmationEvent | None:
200+
"""Try to parse resume data as a tool-call confirmation event.
201+
202+
Returns the parsed confirmation if valid, None otherwise (e.g. endToolCall).
203+
"""
204+
try:
205+
return UiPathConversationToolCallConfirmationEvent.model_validate(data)
206+
except (ValidationError, TypeError):
207+
return None

tests/test_chat.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
UiPathConversationMessageEvent,
1111
UiPathConversationMessageStartEvent,
1212
)
13-
from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType
13+
from uipath.core.triggers import (
14+
UiPathApiTrigger,
15+
UiPathResumeTrigger,
16+
UiPathResumeTriggerType,
17+
)
1418

1519
from uipath.runtime import (
1620
UiPathExecuteOptions,
@@ -22,6 +26,7 @@
2226
UiPathChatProtocol,
2327
UiPathChatRuntime,
2428
)
29+
from uipath.runtime.chat.runtime import _parse_confirmation
2530
from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeMessageEvent
2631
from uipath.runtime.schema import UiPathRuntimeSchema
2732

@@ -34,6 +39,7 @@ def make_chat_bridge_mock() -> UiPathChatProtocol:
3439
bridge_mock.disconnect = AsyncMock()
3540
bridge_mock.emit_message_event = AsyncMock()
3641
bridge_mock.emit_interrupt_event = AsyncMock()
42+
bridge_mock.emit_executing_tool_call_event = AsyncMock()
3743
bridge_mock.wait_for_resume = AsyncMock()
3844

3945
return cast(UiPathChatProtocol, bridge_mock)
@@ -144,6 +150,13 @@ async def stream(
144150
trigger = UiPathResumeTrigger(
145151
interrupt_id="interrupt-1",
146152
trigger_type=UiPathResumeTriggerType.API,
153+
api_resume=UiPathApiTrigger(
154+
request={
155+
"tool_call_id": "tc-1",
156+
"tool_name": "test_tool",
157+
"input": {"key": "value"},
158+
}
159+
),
147160
payload={"action": "confirm_tool_call"},
148161
)
149162
yield UiPathRuntimeResult(
@@ -414,16 +427,37 @@ async def stream(
414427
trigger_a = UiPathResumeTrigger(
415428
interrupt_id="email-confirm",
416429
trigger_type=UiPathResumeTriggerType.API,
430+
api_resume=UiPathApiTrigger(
431+
request={
432+
"tool_call_id": "tc-email",
433+
"tool_name": "send_email",
434+
"input": {"to": "user@example.com"},
435+
}
436+
),
417437
payload={"action": "send_email", "to": "user@example.com"},
418438
)
419439
trigger_b = UiPathResumeTrigger(
420440
interrupt_id="file-delete",
421441
trigger_type=UiPathResumeTriggerType.API,
442+
api_resume=UiPathApiTrigger(
443+
request={
444+
"tool_call_id": "tc-file",
445+
"tool_name": "delete_file",
446+
"input": {"path": "/logs/old.txt"},
447+
}
448+
),
422449
payload={"action": "delete_file", "path": "/logs/old.txt"},
423450
)
424451
trigger_c = UiPathResumeTrigger(
425452
interrupt_id="api-call",
426453
trigger_type=UiPathResumeTriggerType.API,
454+
api_resume=UiPathApiTrigger(
455+
request={
456+
"tool_call_id": "tc-api",
457+
"tool_name": "call_api",
458+
"input": {"endpoint": "/users"},
459+
}
460+
),
427461
payload={"action": "call_api", "endpoint": "/users"},
428462
)
429463

@@ -483,11 +517,25 @@ async def stream(
483517
trigger_a = UiPathResumeTrigger(
484518
interrupt_id="email-confirm",
485519
trigger_type=UiPathResumeTriggerType.API,
520+
api_resume=UiPathApiTrigger(
521+
request={
522+
"tool_call_id": "tc-email",
523+
"tool_name": "send_email",
524+
"input": {"to": "user@example.com"},
525+
}
526+
),
486527
payload={"action": "send_email"},
487528
)
488529
trigger_b = UiPathResumeTrigger(
489530
interrupt_id="file-delete",
490531
trigger_type=UiPathResumeTriggerType.API,
532+
api_resume=UiPathApiTrigger(
533+
request={
534+
"tool_call_id": "tc-file",
535+
"tool_name": "delete_file",
536+
"input": {"path": "/logs/old.txt"},
537+
}
538+
),
491539
payload={"action": "delete_file"},
492540
)
493541
trigger_c = UiPathResumeTrigger(
@@ -612,3 +660,101 @@ async def test_chat_runtime_filters_non_api_triggers():
612660
assert emit_calls[0][0][0].trigger_type == UiPathResumeTriggerType.API
613661
assert emit_calls[1][0][0].interrupt_id == "file-delete"
614662
assert emit_calls[1][0][0].trigger_type == UiPathResumeTriggerType.API
663+
664+
665+
class TestParseConfirmation:
666+
def test_approved_confirmation(self):
667+
result = _parse_confirmation({"approved": True})
668+
assert result is not None
669+
assert result.approved is True
670+
assert result.input is None
671+
672+
def test_rejected_confirmation(self):
673+
result = _parse_confirmation({"approved": False})
674+
assert result is not None
675+
assert result.approved is False
676+
677+
def test_confirmation_with_modified_input(self):
678+
result = _parse_confirmation({"approved": True, "input": {"key": "new_val"}})
679+
assert result is not None
680+
assert result.approved is True
681+
assert result.input == {"key": "new_val"}
682+
683+
def test_end_tool_call_returns_none(self):
684+
result = _parse_confirmation({"output": {"result": 42}, "isError": False})
685+
assert result is None
686+
687+
def test_empty_dict_returns_none(self):
688+
result = _parse_confirmation({})
689+
assert result is None
690+
691+
def test_unrelated_data_returns_none(self):
692+
result = _parse_confirmation({"foo": "bar", "baz": 123})
693+
assert result is None
694+
695+
696+
@pytest.mark.asyncio
697+
async def test_confirmation_approved_emits_executing_tool_call_event():
698+
"""Approved confirmation should emit executingToolCall with original input."""
699+
runtime_impl = SuspendingMockRuntime(suspend_at_message=0)
700+
bridge = make_chat_bridge_mock()
701+
cast(AsyncMock, bridge.wait_for_resume).return_value = {"approved": True}
702+
703+
chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge)
704+
await chat_runtime.execute({})
705+
await chat_runtime.dispose()
706+
707+
cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_awaited_once_with(
708+
tool_call_id="tc-1",
709+
tool_input={"key": "value"},
710+
)
711+
712+
713+
@pytest.mark.asyncio
714+
async def test_confirmation_approved_with_modified_input():
715+
"""Approved confirmation with modified input should use confirmation input."""
716+
runtime_impl = SuspendingMockRuntime(suspend_at_message=0)
717+
bridge = make_chat_bridge_mock()
718+
cast(AsyncMock, bridge.wait_for_resume).return_value = {
719+
"approved": True,
720+
"input": {"key": "modified"},
721+
}
722+
723+
chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge)
724+
await chat_runtime.execute({})
725+
await chat_runtime.dispose()
726+
727+
cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_awaited_once_with(
728+
tool_call_id="tc-1",
729+
tool_input={"key": "modified"},
730+
)
731+
732+
733+
@pytest.mark.asyncio
734+
async def test_confirmation_rejected_does_not_emit_executing():
735+
"""Rejected confirmation should not emit executingToolCall."""
736+
runtime_impl = SuspendingMockRuntime(suspend_at_message=0)
737+
bridge = make_chat_bridge_mock()
738+
cast(AsyncMock, bridge.wait_for_resume).return_value = {"approved": False}
739+
740+
chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge)
741+
await chat_runtime.execute({})
742+
await chat_runtime.dispose()
743+
744+
cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_not_awaited()
745+
746+
747+
@pytest.mark.asyncio
748+
async def test_end_tool_call_does_not_emit_executing():
749+
"""endToolCall resume data should not emit executingToolCall."""
750+
runtime_impl = SuspendingMockRuntime(suspend_at_message=0)
751+
bridge = make_chat_bridge_mock()
752+
cast(AsyncMock, bridge.wait_for_resume).return_value = {
753+
"output": {"result": 42},
754+
}
755+
756+
chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge)
757+
await chat_runtime.execute({})
758+
await chat_runtime.dispose()
759+
760+
cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_not_awaited()

uv.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)