Skip to content

Commit 87ccb04

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

File tree

10 files changed

+333
-27
lines changed

10 files changed

+333
-27
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.3.0"
3+
version = "0.3.1"
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/__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: 47 additions & 17 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,32 @@ 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-
128-
return resume_data
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+
assert trigger.interrupt_id is not None, (
133+
"Trigger interrupt_id cannot be None"
134+
)
135+
resume_map[trigger.interrupt_id] = data
136+
await self.storage.delete_trigger(self.runtime_id, trigger)
137+
except UiPathPendingTriggerError:
138+
# Trigger still pending, skip it
139+
pass
140+
141+
return resume_map
129142

130143
async def _handle_suspension(
131144
self, result: UiPathRuntimeResult
@@ -142,22 +155,39 @@ async def _handle_suspension(
142155
if isinstance(result, UiPathBreakpointResult):
143156
return result
144157

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-
150158
suspended_result = UiPathRuntimeResult(
151159
status=UiPathRuntimeStatus.SUSPENDED,
152160
output=result.output,
153161
)
154162

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

160-
await self.storage.save_trigger(self.runtime_id, suspended_result.trigger)
188+
# Backward compatibility: set single trigger directly
189+
if len(suspended_result.triggers) == 1:
190+
suspended_result.trigger = suspended_result.triggers[0]
161191

162192
return suspended_result
163193

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)