Skip to content

Commit 73b0665

Browse files
committed
fix: add support for multiple resume triggers
1 parent 5bc94c3 commit 73b0665

File tree

8 files changed

+315
-24
lines changed

8 files changed

+315
-24
lines changed

src/uipath/runtime/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
from uipath.runtime.chat.runtime import UiPathChatRuntime
1212
from uipath.runtime.context import UiPathRuntimeContext
1313
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
14-
from uipath.runtime.debug.bridge import UiPathDebugProtocol
1514
from uipath.runtime.debug.exception import UiPathDebugQuitError
15+
from uipath.runtime.debug.protocol import UiPathDebugProtocol
1616
from uipath.runtime.debug.runtime import (
1717
UiPathDebugRuntime,
1818
)

src/uipath/runtime/debug/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Initialization module for the debug package."""
22

33
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
4-
from uipath.runtime.debug.bridge import UiPathDebugProtocol
54
from uipath.runtime.debug.exception import (
65
UiPathDebugQuitError,
76
)
7+
from uipath.runtime.debug.protocol import UiPathDebugProtocol
88
from uipath.runtime.debug.runtime import UiPathDebugRuntime
99

1010
__all__ = [

src/uipath/runtime/resumable/protocols.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,44 @@
88
class UiPathResumableStorageProtocol(Protocol):
99
"""Protocol for storing and retrieving resume triggers."""
1010

11-
async def save_trigger(self, runtime_id: str, trigger: UiPathResumeTrigger) -> None:
12-
"""Save a resume trigger to storage.
11+
async def save_triggers(
12+
self, runtime_id: str, triggers: list[UiPathResumeTrigger]
13+
) -> None:
14+
"""Save resume triggers to storage.
1315
1416
Args:
15-
trigger: The resume trigger to persist
17+
triggers: The resume triggers to persist
1618
1719
Raises:
1820
Exception: If storage operation fails
1921
"""
2022
...
2123

22-
async def get_latest_trigger(self, runtime_id: str) -> UiPathResumeTrigger | None:
23-
"""Retrieve the most recent resume trigger from storage.
24+
async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger] | None:
25+
"""Retrieve the resume triggers from storage.
2426
2527
Returns:
26-
The latest resume trigger, or None if no triggers exist
28+
The resume triggers, or None if no triggers exist
2729
2830
Raises:
2931
Exception: If retrieval operation fails
3032
"""
3133
...
3234

35+
async def delete_trigger(
36+
self, runtime_id: str, trigger: UiPathResumeTrigger
37+
) -> None:
38+
"""Delete resume trigger from storage.
39+
40+
Args:
41+
runtime_id: The runtime ID
42+
trigger: The resume trigger to delete
43+
44+
Raises:
45+
Exception: If deletion operation fails
46+
"""
47+
...
48+
3349
async def set_value(
3450
self, runtime_id: str, namespace: str, key: str, value: Any
3551
) -> None:

src/uipath/runtime/resumable/runtime.py

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

6+
from uipath.core.errors import UiPathPendingTriggerError
7+
68
from uipath.runtime.base import (
79
UiPathExecuteOptions,
810
UiPathRuntimeProtocol,
@@ -111,21 +113,29 @@ async def _restore_resume_input(
111113
input: User-provided input (takes precedence)
112114
113115
Returns:
114-
Input to use for resume, either provided or from storage
116+
Input to use for resume: {interrupt_id: resume_data, ...}
115117
"""
116118
# If user provided explicit input, use it
117119
if input is not None:
118120
return input
119121

120-
# Otherwise, fetch from storage
121-
trigger = await self.storage.get_latest_trigger(self.runtime_id)
122-
if not trigger:
122+
# Fetch all triggers from storage
123+
triggers = await self.storage.get_triggers(self.runtime_id)
124+
if not triggers:
123125
return None
124126

125-
# Read trigger data via trigger_manager
126-
resume_data = await self.trigger_manager.read_trigger(trigger)
127+
# Build resume map: {interrupt_id: resume_data}
128+
resume_map: dict[str, Any] = {}
129+
for trigger in triggers:
130+
try:
131+
data = await self.trigger_manager.read_trigger(trigger)
132+
resume_map[trigger.interrupt_id] = data
133+
await self.storage.delete_trigger(self.runtime_id, trigger)
134+
except UiPathPendingTriggerError:
135+
# Trigger still pending, skip it
136+
pass
127137

128-
return resume_data
138+
return resume_map
129139

130140
async def _handle_suspension(
131141
self, result: UiPathRuntimeResult
@@ -142,22 +152,39 @@ async def _handle_suspension(
142152
if isinstance(result, UiPathBreakpointResult):
143153
return result
144154

145-
# Check if trigger already exists in result
146-
if result.trigger:
147-
await self.storage.save_trigger(self.runtime_id, result.trigger)
148-
return result
149-
150155
suspended_result = UiPathRuntimeResult(
151156
status=UiPathRuntimeStatus.SUSPENDED,
152157
output=result.output,
153158
)
154159

155-
if result.output:
156-
suspended_result.trigger = await self.trigger_manager.create_trigger(
157-
result.output
160+
assert result.output is None or isinstance(result.output, dict), (
161+
"Suspended runtime output must be a dict of interrupt IDs to resume data"
162+
)
163+
164+
# Get existing triggers and current interrupts
165+
suspended_result.triggers = (
166+
await self.storage.get_triggers(self.runtime_id) or []
167+
)
168+
current_interrupts = result.output or {}
169+
170+
# Diff: find new interrupts
171+
existing_ids = {t.interrupt_id for t in suspended_result.triggers}
172+
new_ids = current_interrupts.keys() - existing_ids
173+
174+
# Create triggers only for new interrupts
175+
for interrupt_id in new_ids:
176+
trigger = await self.trigger_manager.create_trigger(
177+
current_interrupts[interrupt_id]
158178
)
179+
trigger.interrupt_id = interrupt_id
180+
suspended_result.triggers.append(trigger)
181+
182+
if suspended_result.triggers:
183+
await self.storage.save_triggers(self.runtime_id, suspended_result.triggers)
159184

160-
await self.storage.save_trigger(self.runtime_id, suspended_result.trigger)
185+
# Backward compatibility: set single trigger directly
186+
if len(suspended_result.triggers) == 1:
187+
suspended_result.trigger = suspended_result.triggers[0]
161188

162189
return suspended_result
163190

src/uipath/runtime/resumable/trigger.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class UiPathApiTrigger(BaseModel):
4949
class UiPathResumeTrigger(BaseModel):
5050
"""Information needed to resume execution."""
5151

52+
interrupt_id: str | None = Field(default=None, alias="interruptId")
5253
trigger_type: UiPathResumeTriggerType = Field(
5354
default=UiPathResumeTriggerType.API, alias="triggerType"
5455
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ async def stream(
145145
yield UiPathRuntimeResult(
146146
status=UiPathRuntimeStatus.SUSPENDED,
147147
trigger=UiPathResumeTrigger(
148+
interrupt_id="interrupt-1",
148149
trigger_type=UiPathResumeTriggerType.API,
149150
payload={"action": "confirm_tool_call"},
150151
),

0 commit comments

Comments
 (0)