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
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-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"
Expand Down
16 changes: 16 additions & 0 deletions src/uipath/runtime/chat/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ async def emit_interrupt_event(
"""
...

async def emit_executing_tool_call_event(
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."""
...
Expand Down
43 changes: 43 additions & 0 deletions src/uipath/runtime/chat/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -108,6 +110,34 @@ async def stream(
await self.chat_bridge.wait_for_resume()
)

# If this was a tool-call confirmation,
# emit executingToolCall with the final input.
# This allows client side tools to run after any tool confirmation.
confirmation = _parse_confirmation(resume_data)
if confirmation and confirmation.approved:
assert trigger.api_resume is not None, (
"Confirmed trigger must have api_resume"
)
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,
)

assert trigger.interrupt_id is not None, (
"Trigger interrupt_id cannot be None"
)
Expand Down Expand Up @@ -162,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
148 changes: 147 additions & 1 deletion tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"},
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading