From aa9841fbbeadbc88541dae08fd56fdbf27c89d3e Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 11:49:35 -0400 Subject: [PATCH 1/6] feat: emit executing tool call event for client-side tools --- src/uipath/runtime/chat/protocol.py | 16 +++++++++++++++ src/uipath/runtime/chat/runtime.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/uipath/runtime/chat/protocol.py b/src/uipath/runtime/chat/protocol.py index cc86957..081610f 100644 --- a/src/uipath/runtime/chat/protocol.py +++ b/src/uipath/runtime/chat/protocol.py @@ -43,6 +43,22 @@ async def emit_interrupt_event( """ ... + async def emit_executing_tool_call( + self, + tool_call_id: str, + tool_input: dict[str, Any] | None = None, + ) -> None: + """Emit an executingToolCall event. + + Called after a tool-call confirmation resumes to signal that the tool + is about to execute with the final (possibly modified) input. + + Args: + tool_call_id: The tool call ID from the interrupt request. + tool_input: The final tool input after confirmation. + """ + ... + async def emit_exchange_end_event(self) -> None: """Send an exchange end event.""" ... diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index 07753a2..16e9d2e 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -108,6 +108,37 @@ async def stream( await self.chat_bridge.wait_for_resume() ) + # If this was a tool-call confirmation (has "approved"), + # emit executingToolCall with the final input. + # This allows client side tools to run after any tool confirmation. + if ( + isinstance(resume_data, dict) + and "approved" in resume_data + and resume_data.get("approved") + ): + request = ( + trigger.api_resume.request + if trigger.api_resume + else None + ) + tool_call_id = ( + request.get("tool_call_id") + if isinstance(request, dict) + else None + ) + if tool_call_id: + confirmed_input = resume_data.get( + "input" + ) or ( + request.get("input") + if isinstance(request, dict) + else None + ) + await self.chat_bridge.emit_executing_tool_call( + tool_call_id=tool_call_id, + tool_input=confirmed_input, + ) + assert trigger.interrupt_id is not None, ( "Trigger interrupt_id cannot be None" ) From 2b45546dbeb249541869a4fc227cac0839d51ca5 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 14:57:07 -0400 Subject: [PATCH 2/6] chore: update comments and checks with parsing --- src/uipath/runtime/chat/protocol.py | 2 +- src/uipath/runtime/chat/runtime.py | 64 +++++++++++++++++------------ 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/uipath/runtime/chat/protocol.py b/src/uipath/runtime/chat/protocol.py index 081610f..aa7930c 100644 --- a/src/uipath/runtime/chat/protocol.py +++ b/src/uipath/runtime/chat/protocol.py @@ -43,7 +43,7 @@ async def emit_interrupt_event( """ ... - async def emit_executing_tool_call( + async def emit_executing_tool_call_event( self, tool_call_id: str, tool_input: dict[str, Any] | None = None, diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index 16e9d2e..040040b 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -3,6 +3,8 @@ import logging from typing import Any, AsyncGenerator, cast +from pydantic import ValidationError +from uipath.core.chat import UiPathConversationToolCallConfirmationEvent from uipath.core.triggers import UiPathResumeTriggerType from uipath.runtime.base import ( @@ -108,36 +110,33 @@ async def stream( await self.chat_bridge.wait_for_resume() ) - # If this was a tool-call confirmation (has "approved"), + # If this was a tool-call confirmation, # emit executingToolCall with the final input. # This allows client side tools to run after any tool confirmation. - if ( - isinstance(resume_data, dict) - and "approved" in resume_data - and resume_data.get("approved") - ): - request = ( - trigger.api_resume.request - if trigger.api_resume - else None + confirmation = _parse_confirmation(resume_data) + if confirmation and confirmation.approved: + assert trigger.api_resume is not None, ( + "Confirmed trigger must have api_resume" ) - tool_call_id = ( - request.get("tool_call_id") - if isinstance(request, dict) - else None + request = trigger.api_resume.request + assert isinstance(request, dict), ( + "Confirmed trigger api_resume.request must be a dict" + ) + + tool_call_id = request.get("tool_call_id") + assert tool_call_id is not None, ( + "Confirmed trigger request must contain tool_call_id" + ) + + confirmed_input = ( + confirmation.input + if confirmation.input is not None + else request.get("input") + ) + await self.chat_bridge.emit_executing_tool_call_event( + tool_call_id=tool_call_id, + tool_input=confirmed_input, ) - if tool_call_id: - confirmed_input = resume_data.get( - "input" - ) or ( - request.get("input") - if isinstance(request, dict) - else None - ) - await self.chat_bridge.emit_executing_tool_call( - tool_call_id=tool_call_id, - tool_input=confirmed_input, - ) assert trigger.interrupt_id is not None, ( "Trigger interrupt_id cannot be None" @@ -193,3 +192,16 @@ async def dispose(self) -> None: await self.chat_bridge.disconnect() except Exception as e: logger.warning(f"Error disconnecting chat bridge: {e}") + + +def _parse_confirmation( + data: dict[str, Any], +) -> UiPathConversationToolCallConfirmationEvent | None: + """Try to parse resume data as a tool-call confirmation event. + + Returns the parsed confirmation if valid, None otherwise (e.g. endToolCall). + """ + try: + return UiPathConversationToolCallConfirmationEvent.model_validate(data) + except (ValidationError, TypeError): + return None From 6f2d4c817ca5a9dca1d5ff57a8215feb41c73f9c Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 14:57:19 -0400 Subject: [PATCH 3/6] test: add tests --- tests/test_chat.py | 148 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/tests/test_chat.py b/tests/test_chat.py index 8110ab8..c3be06b 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -10,7 +10,11 @@ UiPathConversationMessageEvent, UiPathConversationMessageStartEvent, ) -from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType +from uipath.core.triggers import ( + UiPathApiTrigger, + UiPathResumeTrigger, + UiPathResumeTriggerType, +) from uipath.runtime import ( UiPathExecuteOptions, @@ -22,6 +26,7 @@ UiPathChatProtocol, UiPathChatRuntime, ) +from uipath.runtime.chat.runtime import _parse_confirmation from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeMessageEvent from uipath.runtime.schema import UiPathRuntimeSchema @@ -34,6 +39,7 @@ def make_chat_bridge_mock() -> UiPathChatProtocol: bridge_mock.disconnect = AsyncMock() bridge_mock.emit_message_event = AsyncMock() bridge_mock.emit_interrupt_event = AsyncMock() + bridge_mock.emit_executing_tool_call_event = AsyncMock() bridge_mock.wait_for_resume = AsyncMock() return cast(UiPathChatProtocol, bridge_mock) @@ -144,6 +150,13 @@ async def stream( trigger = UiPathResumeTrigger( interrupt_id="interrupt-1", trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-1", + "tool_name": "test_tool", + "input": {"key": "value"}, + } + ), payload={"action": "confirm_tool_call"}, ) yield UiPathRuntimeResult( @@ -414,16 +427,37 @@ async def stream( trigger_a = UiPathResumeTrigger( interrupt_id="email-confirm", trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-email", + "tool_name": "send_email", + "input": {"to": "user@example.com"}, + } + ), payload={"action": "send_email", "to": "user@example.com"}, ) trigger_b = UiPathResumeTrigger( interrupt_id="file-delete", trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-file", + "tool_name": "delete_file", + "input": {"path": "/logs/old.txt"}, + } + ), payload={"action": "delete_file", "path": "/logs/old.txt"}, ) trigger_c = UiPathResumeTrigger( interrupt_id="api-call", trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-api", + "tool_name": "call_api", + "input": {"endpoint": "/users"}, + } + ), payload={"action": "call_api", "endpoint": "/users"}, ) @@ -483,11 +517,25 @@ async def stream( trigger_a = UiPathResumeTrigger( interrupt_id="email-confirm", trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-email", + "tool_name": "send_email", + "input": {"to": "user@example.com"}, + } + ), payload={"action": "send_email"}, ) trigger_b = UiPathResumeTrigger( interrupt_id="file-delete", trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-file", + "tool_name": "delete_file", + "input": {"path": "/logs/old.txt"}, + } + ), payload={"action": "delete_file"}, ) trigger_c = UiPathResumeTrigger( @@ -612,3 +660,101 @@ async def test_chat_runtime_filters_non_api_triggers(): assert emit_calls[0][0][0].trigger_type == UiPathResumeTriggerType.API assert emit_calls[1][0][0].interrupt_id == "file-delete" assert emit_calls[1][0][0].trigger_type == UiPathResumeTriggerType.API + + +class TestParseConfirmation: + def test_approved_confirmation(self): + result = _parse_confirmation({"approved": True}) + assert result is not None + assert result.approved is True + assert result.input is None + + def test_rejected_confirmation(self): + result = _parse_confirmation({"approved": False}) + assert result is not None + assert result.approved is False + + def test_confirmation_with_modified_input(self): + result = _parse_confirmation({"approved": True, "input": {"key": "new_val"}}) + assert result is not None + assert result.approved is True + assert result.input == {"key": "new_val"} + + def test_end_tool_call_returns_none(self): + result = _parse_confirmation({"output": {"result": 42}, "isError": False}) + assert result is None + + def test_empty_dict_returns_none(self): + result = _parse_confirmation({}) + assert result is None + + def test_unrelated_data_returns_none(self): + result = _parse_confirmation({"foo": "bar", "baz": 123}) + assert result is None + + +@pytest.mark.asyncio +async def test_confirmation_approved_emits_executing_tool_call_event(): + """Approved confirmation should emit executingToolCall with original input.""" + runtime_impl = SuspendingMockRuntime(suspend_at_message=0) + bridge = make_chat_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).return_value = {"approved": True} + + chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge) + await chat_runtime.execute({}) + await chat_runtime.dispose() + + cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_awaited_once_with( + tool_call_id="tc-1", + tool_input={"key": "value"}, + ) + + +@pytest.mark.asyncio +async def test_confirmation_approved_with_modified_input(): + """Approved confirmation with modified input should use confirmation input.""" + runtime_impl = SuspendingMockRuntime(suspend_at_message=0) + bridge = make_chat_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).return_value = { + "approved": True, + "input": {"key": "modified"}, + } + + chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge) + await chat_runtime.execute({}) + await chat_runtime.dispose() + + cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_awaited_once_with( + tool_call_id="tc-1", + tool_input={"key": "modified"}, + ) + + +@pytest.mark.asyncio +async def test_confirmation_rejected_does_not_emit_executing(): + """Rejected confirmation should not emit executingToolCall.""" + runtime_impl = SuspendingMockRuntime(suspend_at_message=0) + bridge = make_chat_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).return_value = {"approved": False} + + chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge) + await chat_runtime.execute({}) + await chat_runtime.dispose() + + cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_not_awaited() + + +@pytest.mark.asyncio +async def test_end_tool_call_does_not_emit_executing(): + """endToolCall resume data should not emit executingToolCall.""" + runtime_impl = SuspendingMockRuntime(suspend_at_message=0) + bridge = make_chat_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).return_value = { + "output": {"result": 42}, + } + + chat_runtime = UiPathChatRuntime(delegate=runtime_impl, chat_bridge=bridge) + await chat_runtime.execute({}) + await chat_runtime.dispose() + + cast(AsyncMock, bridge.emit_executing_tool_call_event).assert_not_awaited() From 0e935bfc12f79c6730e4b1fd0d7f64c683a3b6cc Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 14:57:43 -0400 Subject: [PATCH 4/6] chore: bump ver --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 018cbe1..4dbe605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.10.4" +version = "0.10.5" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index d0a9a24..0db9ff5 100644 --- a/uv.lock +++ b/uv.lock @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.10.4" +version = "0.10.5" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From 2e4f05bf0eecc570988cdf97c399a47e122bce90 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 19:02:13 -0400 Subject: [PATCH 5/6] chore: update ver --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 0db9ff5..0b56264 100644 --- a/uv.lock +++ b/uv.lock @@ -998,7 +998,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.15" +version = "0.5.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, From 303b206fa6eee0246d43a6583d7153d2aeaf37d1 Mon Sep 17 00:00:00 2001 From: Norman Le Date: Thu, 28 May 2026 21:59:54 -0400 Subject: [PATCH 6/6] chore: update package --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 0b56264..8d74439 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-05-27T01:59:29.217327Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1005,9 +1005,9 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/61/945ed075095ab2b4e5e4a43f3724f7d7d2e8897267e11e34bce290df96de/uipath_core-0.5.15.tar.gz", hash = "sha256:dc1049bff52029313f213ab87a6b39acae76c462543729fcea80ea5ac23366b5", size = 117892, upload-time = "2026-04-30T07:39:01.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/80/a626eb3136a6765e0af06c9d5080ac0843c2a72f17b7a2170f1f45da40dd/uipath_core-0.5.17.tar.gz", hash = "sha256:13565e1eba9f059a8221494dfb3239257ddf7f265fc7057199ffe03ed066300a", size = 119023, upload-time = "2026-05-28T21:34:10.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/e4/e763bb94dd08ea93e98937b1a229a29f2907bee75a273a0594d929528133/uipath_core-0.5.15-py3-none-any.whl", hash = "sha256:8aa50b1f1531151ef827d29454d63e409c38699cd5a5d63f943094f2b7d5ddd7", size = 44575, upload-time = "2026-04-30T07:38:59.427Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/f4b481970621e2a9aec869302773fa2c7d346aef294a553429626369633f/uipath_core-0.5.17-py3-none-any.whl", hash = "sha256:6e088eec5130bc492ac176ab85d4924d7d4cb07ee290ed7e6a46984e9de8c12b", size = 44957, upload-time = "2026-05-28T21:34:09.534Z" }, ] [[package]]