Skip to content

Commit db127cd

Browse files
feat: update chat runtime
1 parent c247ae7 commit db127cd

File tree

6 files changed

+213
-10
lines changed

6 files changed

+213
-10
lines changed

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.2.9"
3+
version = "0.3.0"
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: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
"""Abstract conversation bridge interface."""
22

3-
from typing import Protocol
3+
from typing import Any, Protocol
44

5-
from uipath.core.chat import UiPathConversationMessageEvent
5+
from uipath.core.chat import (
6+
UiPathConversationMessageEvent,
7+
)
8+
9+
from uipath.runtime.result import UiPathRuntimeResult
610

711

812
class UiPathChatProtocol(Protocol):
@@ -28,3 +32,22 @@ async def emit_message_event(
2832
message_event: UiPathConversationMessageEvent to wrap and send
2933
"""
3034
...
35+
36+
async def emit_interrupt_event(
37+
self,
38+
interrupt_event: UiPathRuntimeResult,
39+
) -> None:
40+
"""Wrap and send an interrupt event.
41+
42+
Args:
43+
interrupt_event: UiPathConversationInterruptEvent to wrap and send
44+
"""
45+
...
46+
47+
async def emit_exchange_end_event(self) -> None:
48+
"""Send an exchange end event."""
49+
...
50+
51+
async def wait_for_resume(self) -> dict[str, Any]:
52+
"""Wait for the interrupt_end event to be received."""
53+
...

src/uipath/runtime/chat/runtime.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
UiPathRuntimeResult,
1818
UiPathRuntimeStatus,
1919
)
20+
from uipath.runtime.resumable.trigger import UiPathResumeTriggerType
2021
from uipath.runtime.schema import UiPathRuntimeSchema
2122

2223
logger = logging.getLogger(__name__)
@@ -65,12 +66,44 @@ async def stream(
6566
"""Stream execution events with chat support."""
6667
await self.chat_bridge.connect()
6768

68-
async for event in self.delegate.stream(input, options=options):
69-
if isinstance(event, UiPathRuntimeMessageEvent):
70-
if event.payload:
71-
await self.chat_bridge.emit_message_event(event.payload)
69+
execution_completed = False
70+
current_input = input
71+
current_options = UiPathStreamOptions(
72+
resume=options.resume if options else False,
73+
breakpoints=options.breakpoints if options else None,
74+
)
75+
76+
while not execution_completed:
77+
async for event in self.delegate.stream(
78+
current_input, options=current_options
79+
):
80+
if isinstance(event, UiPathRuntimeMessageEvent):
81+
if event.payload:
82+
await self.chat_bridge.emit_message_event(event.payload)
83+
84+
if isinstance(event, UiPathRuntimeResult):
85+
runtime_result = event
86+
87+
if (
88+
runtime_result.status == UiPathRuntimeStatus.SUSPENDED
89+
and runtime_result.trigger
90+
and runtime_result.trigger.trigger_type
91+
== UiPathResumeTriggerType.API
92+
):
93+
await self.chat_bridge.emit_interrupt_event(runtime_result)
94+
resume_data = await self.chat_bridge.wait_for_resume()
95+
96+
# Continue with resumed execution
97+
current_input = resume_data
98+
current_options.resume = True
99+
break
100+
else:
101+
yield event
102+
execution_completed = True
103+
else:
104+
yield event
72105

73-
yield event
106+
await self.chat_bridge.emit_exchange_end_event()
74107

75108
async def get_schema(self) -> UiPathRuntimeSchema:
76109
"""Get schema from the delegate runtime."""

src/uipath/runtime/resumable/runtime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
UiPathRuntimeProtocol,
99
UiPathStreamOptions,
1010
)
11-
from uipath.runtime.debug import UiPathBreakpointResult
11+
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
1212
from uipath.runtime.events import UiPathRuntimeEvent
1313
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
1414
from uipath.runtime.resumable.protocols import (

tests/test_chat_runtime.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from uipath.runtime import (
1515
UiPathExecuteOptions,
16+
UiPathResumeTrigger,
17+
UiPathResumeTriggerType,
1618
UiPathRuntimeResult,
1719
UiPathRuntimeStatus,
1820
UiPathStreamOptions,
@@ -32,6 +34,8 @@ def make_chat_bridge_mock() -> UiPathChatProtocol:
3234
bridge_mock.connect = AsyncMock()
3335
bridge_mock.disconnect = AsyncMock()
3436
bridge_mock.emit_message_event = AsyncMock()
37+
bridge_mock.emit_interrupt_event = AsyncMock()
38+
bridge_mock.wait_for_resume = AsyncMock()
3539

3640
return cast(UiPathChatProtocol, bridge_mock)
3741

@@ -94,6 +98,79 @@ async def get_schema(self) -> UiPathRuntimeSchema:
9498
raise NotImplementedError()
9599

96100

101+
class SuspendingMockRuntime:
102+
"""Mock runtime that can suspend with API triggers."""
103+
104+
def __init__(
105+
self,
106+
suspend_at_message: int | None = None,
107+
) -> None:
108+
self.suspend_at_message = suspend_at_message
109+
110+
async def dispose(self) -> None:
111+
pass
112+
113+
async def execute(
114+
self,
115+
input: dict[str, Any] | None = None,
116+
options: UiPathExecuteOptions | None = None,
117+
) -> UiPathRuntimeResult:
118+
"""Fallback execute path."""
119+
return UiPathRuntimeResult(
120+
status=UiPathRuntimeStatus.SUCCESSFUL,
121+
output={"mode": "execute"},
122+
)
123+
124+
async def stream(
125+
self,
126+
input: dict[str, Any] | None = None,
127+
options: UiPathStreamOptions | None = None,
128+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
129+
"""Stream events with potential API trigger suspension."""
130+
is_resume = options and options.resume
131+
132+
if not is_resume:
133+
# Initial execution - yield message and then suspend
134+
message_event = UiPathConversationMessageEvent(
135+
message_id="msg-0",
136+
start=UiPathConversationMessageStartEvent(
137+
role="assistant",
138+
timestamp="2025-01-01T00:00:00.000Z",
139+
),
140+
)
141+
yield UiPathRuntimeMessageEvent(payload=message_event)
142+
143+
if self.suspend_at_message is not None:
144+
# Suspend with API trigger
145+
yield UiPathRuntimeResult(
146+
status=UiPathRuntimeStatus.SUSPENDED,
147+
trigger=UiPathResumeTrigger(
148+
trigger_type=UiPathResumeTriggerType.API,
149+
payload={"action": "confirm_tool_call"},
150+
),
151+
)
152+
return
153+
else:
154+
# Resumed execution - yield another message and complete
155+
message_event = UiPathConversationMessageEvent(
156+
message_id="msg-1",
157+
start=UiPathConversationMessageStartEvent(
158+
role="assistant",
159+
timestamp="2025-01-01T00:00:01.000Z",
160+
),
161+
)
162+
yield UiPathRuntimeMessageEvent(payload=message_event)
163+
164+
# Final successful result
165+
yield UiPathRuntimeResult(
166+
status=UiPathRuntimeStatus.SUCCESSFUL,
167+
output={"resumed": is_resume, "input": input},
168+
)
169+
170+
async def get_schema(self) -> UiPathRuntimeSchema:
171+
raise NotImplementedError()
172+
173+
97174
@pytest.mark.asyncio
98175
async def test_chat_runtime_streams_and_emits_messages():
99176
"""UiPathChatRuntime should stream events and emit message events to bridge."""
@@ -221,3 +298,73 @@ async def test_chat_runtime_dispose_suppresses_disconnect_errors():
221298
await chat_runtime.dispose()
222299

223300
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
301+
302+
303+
@pytest.mark.asyncio
304+
async def test_chat_runtime_handles_api_trigger_suspension():
305+
"""UiPathChatRuntime should intercept suspensions and resume execution."""
306+
307+
runtime_impl = SuspendingMockRuntime(suspend_at_message=0)
308+
bridge = make_chat_bridge_mock()
309+
310+
cast(AsyncMock, bridge.wait_for_resume).return_value = {"approved": True}
311+
312+
chat_runtime = UiPathChatRuntime(
313+
delegate=runtime_impl,
314+
chat_bridge=bridge,
315+
)
316+
317+
result = await chat_runtime.execute({})
318+
319+
await chat_runtime.dispose()
320+
321+
# Result should be SUCCESSFUL
322+
assert isinstance(result, UiPathRuntimeResult)
323+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
324+
assert result.output == {"resumed": True, "input": {"approved": True}}
325+
326+
cast(AsyncMock, bridge.connect).assert_awaited_once()
327+
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
328+
329+
cast(AsyncMock, bridge.emit_interrupt_event).assert_awaited_once()
330+
cast(AsyncMock, bridge.wait_for_resume).assert_awaited_once()
331+
332+
# Message events emitted (one before suspend, one after resume)
333+
assert cast(AsyncMock, bridge.emit_message_event).await_count == 2
334+
335+
336+
@pytest.mark.asyncio
337+
async def test_chat_runtime_yields_events_during_suspension_flow():
338+
"""UiPathChatRuntime.stream() should not yield SUSPENDED result, only final result."""
339+
340+
runtime_impl = SuspendingMockRuntime(suspend_at_message=0)
341+
bridge = make_chat_bridge_mock()
342+
343+
# wait_for_resume returns approval data
344+
cast(AsyncMock, bridge.wait_for_resume).return_value = {"approved": True}
345+
346+
chat_runtime = UiPathChatRuntime(
347+
delegate=runtime_impl,
348+
chat_bridge=bridge,
349+
)
350+
351+
events = []
352+
async for event in chat_runtime.stream({}):
353+
events.append(event)
354+
355+
await chat_runtime.dispose()
356+
357+
# Should have 2 message events + 1 final SUCCESSFUL result
358+
# SUSPENDED result should NOT be yielded
359+
assert len(events) == 3
360+
assert isinstance(events[0], UiPathRuntimeMessageEvent)
361+
assert events[0].payload.message_id == "msg-0"
362+
assert isinstance(events[1], UiPathRuntimeMessageEvent)
363+
assert events[1].payload.message_id == "msg-1"
364+
assert isinstance(events[2], UiPathRuntimeResult)
365+
assert events[2].status == UiPathRuntimeStatus.SUCCESSFUL
366+
367+
# Verify no SUSPENDED result was yielded
368+
for event in events:
369+
if isinstance(event, UiPathRuntimeResult):
370+
assert event.status != UiPathRuntimeStatus.SUSPENDED

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)