Skip to content

Commit dcc49ef

Browse files
mjnoviceclaude
andcommitted
feat: add wait_for_triggers option and HeadlessDebugBridge for local polling
Add support for polling triggers instead of suspending, useful for running agents locally without serverless/orchestrator. ## Option 1: Use wait_for_triggers flag (new) Add `wait_for_triggers` and `trigger_poll_interval` options to UiPathExecuteOptions. When enabled, triggers are created but NOT persisted to storage - everything happens inline. ## Option 2: Use UiPathDebugRuntime with HeadlessDebugBridge (recommended by @cristipufu) Add HeadlessDebugBridge - a no-op debug bridge for headless execution that allows using UiPathDebugRuntime's existing polling feature without a debugger UI. Changes: - Add `wait_for_triggers` and `trigger_poll_interval` options to UiPathExecuteOptions - Create shared TriggerPoller utility for reusable polling logic - Update UiPathResumableRuntime to loop and poll when wait_for_triggers=True - Add skip_storage parameter to _handle_suspension() to avoid persistence - Add HeadlessDebugBridge for using UiPathDebugRuntime without a debugger - Refactor UiPathDebugRuntime to use the shared TriggerPoller Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d20dee7 commit dcc49ef

File tree

7 files changed

+465
-87
lines changed

7 files changed

+465
-87
lines changed

src/uipath/runtime/base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ class UiPathExecuteOptions(BaseModel):
4141
default=None,
4242
description="List of nodes or '*' to break on all steps.",
4343
)
44+
wait_for_triggers: bool = Field(
45+
default=False,
46+
description="When True, poll triggers until completion instead of suspending. "
47+
"This keeps the process running and automatically resumes when triggers complete.",
48+
)
49+
trigger_poll_interval: float = Field(
50+
default=5.0,
51+
description="Seconds between poll attempts when wait_for_triggers is True.",
52+
ge=0.1,
53+
)
4454

4555
model_config = {"arbitrary_types_allowed": True, "extra": "allow"}
4656

src/uipath/runtime/debug/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from uipath.runtime.debug.exception import (
55
UiPathDebugQuitError,
66
)
7+
from uipath.runtime.debug.headless_bridge import HeadlessDebugBridge
78
from uipath.runtime.debug.protocol import UiPathDebugProtocol
89
from uipath.runtime.debug.runtime import UiPathDebugRuntime
910

@@ -12,4 +13,5 @@
1213
"UiPathDebugProtocol",
1314
"UiPathDebugRuntime",
1415
"UiPathBreakpointResult",
16+
"HeadlessDebugBridge",
1517
]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Headless debug bridge for running without a debugger UI."""
2+
3+
import asyncio
4+
from typing import Any, Literal
5+
6+
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
7+
from uipath.runtime.events import UiPathRuntimeStateEvent
8+
from uipath.runtime.result import UiPathRuntimeResult
9+
10+
11+
class HeadlessDebugBridge:
12+
"""A no-op debug bridge for headless execution with polling.
13+
14+
Use this when you want to use UiPathDebugRuntime's polling feature
15+
without an actual debugger UI connected.
16+
17+
Example:
18+
```python
19+
from uipath.runtime.debug import UiPathDebugRuntime
20+
from uipath.runtime.debug.headless_bridge import HeadlessDebugBridge
21+
22+
# Wrap your runtime with debug runtime using headless bridge
23+
debug_runtime = UiPathDebugRuntime(
24+
delegate=your_resumable_runtime,
25+
debug_bridge=HeadlessDebugBridge(),
26+
trigger_poll_interval=5.0, # Poll every 5 seconds
27+
)
28+
29+
# Execute - will poll triggers instead of suspending
30+
result = await debug_runtime.execute(input={"query": "hello"})
31+
```
32+
"""
33+
34+
def __init__(self, verbose: bool = False):
35+
"""Initialize the headless debug bridge.
36+
37+
Args:
38+
verbose: If True, print debug events to console
39+
"""
40+
self.verbose = verbose
41+
self._terminate_event = asyncio.Event()
42+
43+
async def connect(self) -> None:
44+
"""No-op connection."""
45+
if self.verbose:
46+
print("[HeadlessDebugBridge] Connected")
47+
48+
async def disconnect(self) -> None:
49+
"""No-op disconnection."""
50+
if self.verbose:
51+
print("[HeadlessDebugBridge] Disconnected")
52+
53+
async def emit_execution_started(self, **kwargs) -> None:
54+
"""Log execution started."""
55+
if self.verbose:
56+
print("[HeadlessDebugBridge] Execution started")
57+
58+
async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None:
59+
"""Log state update."""
60+
if self.verbose:
61+
print(f"[HeadlessDebugBridge] State update: {state_event.node_name}")
62+
63+
async def emit_breakpoint_hit(
64+
self, breakpoint_result: UiPathBreakpointResult
65+
) -> None:
66+
"""Log breakpoint hit (should not happen in headless mode)."""
67+
if self.verbose:
68+
print(f"[HeadlessDebugBridge] Breakpoint hit: {breakpoint_result}")
69+
70+
async def emit_execution_suspended(
71+
self, runtime_result: UiPathRuntimeResult
72+
) -> None:
73+
"""Log execution suspended."""
74+
if self.verbose:
75+
trigger_type = (
76+
runtime_result.trigger.trigger_type
77+
if runtime_result.trigger
78+
else "unknown"
79+
)
80+
print(f"[HeadlessDebugBridge] Execution suspended, trigger: {trigger_type}")
81+
82+
async def emit_execution_resumed(self, resume_data: Any) -> None:
83+
"""Log execution resumed."""
84+
if self.verbose:
85+
print("[HeadlessDebugBridge] Execution resumed with data")
86+
87+
async def emit_execution_completed(
88+
self,
89+
runtime_result: UiPathRuntimeResult,
90+
) -> None:
91+
"""Log execution completed."""
92+
if self.verbose:
93+
print(f"[HeadlessDebugBridge] Execution completed: {runtime_result.status}")
94+
95+
async def emit_execution_error(
96+
self,
97+
error: str,
98+
) -> None:
99+
"""Log execution error."""
100+
if self.verbose:
101+
print(f"[HeadlessDebugBridge] Execution error: {error}")
102+
103+
async def wait_for_resume(self) -> Any:
104+
"""Return immediately to allow execution to continue."""
105+
# In headless mode, we don't wait - just return immediately
106+
return None
107+
108+
async def wait_for_terminate(self) -> None:
109+
"""Wait for termination signal (blocks forever in headless mode)."""
110+
# This should never return in normal operation
111+
# It's used to check if we should stop polling
112+
await self._terminate_event.wait()
113+
114+
def terminate(self) -> None:
115+
"""Signal termination (call this to stop polling)."""
116+
self._terminate_event.set()
117+
118+
def get_breakpoints(self) -> list[str] | Literal["*"]:
119+
"""Return no breakpoints for headless execution."""
120+
return []

src/uipath/runtime/debug/runtime.py

Lines changed: 45 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import logging
55
from typing import Any, AsyncGenerator, cast
66

7-
from uipath.core.errors import UiPathPendingTriggerError
8-
97
from uipath.runtime.base import (
108
UiPathExecuteOptions,
119
UiPathRuntimeProtocol,
@@ -25,7 +23,7 @@
2523
UiPathRuntimeResult,
2624
UiPathRuntimeStatus,
2725
)
28-
from uipath.runtime.resumable.protocols import UiPathResumeTriggerReaderProtocol
26+
from uipath.runtime.resumable.polling import TriggerPoller
2927
from uipath.runtime.resumable.runtime import UiPathResumableRuntime
3028
from uipath.runtime.resumable.trigger import (
3129
UiPathResumeTrigger,
@@ -203,8 +201,7 @@ async def _stream_and_debug(
203201
)
204202
else:
205203
trigger_data = await self._poll_trigger(
206-
final_result.trigger,
207-
self.delegate.trigger_manager,
204+
final_result.trigger
208205
)
209206
resume_data = {interrupt_id: trigger_data}
210207
except UiPathDebugQuitError:
@@ -245,77 +242,65 @@ async def dispose(self) -> None:
245242
logger.warning(f"Error disconnecting debug bridge: {e}")
246243

247244
async def _poll_trigger(
248-
self, trigger: UiPathResumeTrigger, reader: UiPathResumeTriggerReaderProtocol
245+
self, trigger: UiPathResumeTrigger
249246
) -> dict[str, Any] | None:
250247
"""Poll a resume trigger until data is available.
251248
252249
Args:
253250
trigger: The trigger to poll
254-
reader: The trigger reader to use for polling
255251
256252
Returns:
257-
Resume data when available, or None if polling exhausted
253+
Resume data when available, or None if polling was stopped
258254
259255
Raises:
260256
UiPathDebugQuitError: If quit is requested during polling
261257
"""
262-
attempt = 0
263-
while True:
264-
attempt += 1
265-
266-
try:
267-
resume_data = await reader.read_trigger(trigger)
268-
269-
if resume_data is not None:
270-
return resume_data
271-
272-
await self.debug_bridge.emit_state_update(
273-
UiPathRuntimeStateEvent(
274-
node_name="<polling>",
275-
payload={
276-
"attempt": attempt,
277-
},
278-
)
258+
self._quit_requested = False
259+
260+
async def on_poll_attempt(attempt: int, info: str | None) -> None:
261+
"""Callback for each poll attempt."""
262+
payload: dict[str, Any] = {"attempt": attempt}
263+
if info:
264+
payload["info"] = info
265+
await self.debug_bridge.emit_state_update(
266+
UiPathRuntimeStateEvent(
267+
node_name="<polling>",
268+
payload=payload,
279269
)
270+
)
280271

281-
await self._wait_with_quit_check()
282-
283-
except UiPathDebugQuitError:
284-
raise
285-
286-
except UiPathPendingTriggerError as e:
287-
await self.debug_bridge.emit_state_update(
288-
UiPathRuntimeStateEvent(
289-
node_name="<polling>",
290-
payload={
291-
"attempt": attempt,
292-
"info": str(e),
293-
},
294-
)
272+
async def should_stop() -> bool:
273+
"""Check if quit was requested."""
274+
# Check for termination request with a short timeout
275+
try:
276+
term_task = asyncio.create_task(self.debug_bridge.wait_for_terminate())
277+
done, _ = await asyncio.wait(
278+
{term_task},
279+
timeout=0.01, # Very short timeout just to check
295280
)
281+
if term_task in done:
282+
self._quit_requested = True
283+
return True
284+
else:
285+
term_task.cancel()
286+
try:
287+
await term_task
288+
except asyncio.CancelledError:
289+
pass
290+
except Exception:
291+
pass
292+
return False
296293

297-
await self._wait_with_quit_check()
298-
299-
async def _wait_with_quit_check(self) -> None:
300-
"""Wait for specified seconds, but allow quit command to interrupt.
301-
302-
Raises:
303-
UiPathDebugQuitError: If quit is requested during wait
304-
"""
305-
sleep_task = asyncio.create_task(asyncio.sleep(self.trigger_poll_interval))
306-
term_task = asyncio.create_task(self.debug_bridge.wait_for_terminate())
307-
308-
done, pending = await asyncio.wait(
309-
{sleep_task, term_task},
310-
return_when=asyncio.FIRST_COMPLETED,
294+
poller = TriggerPoller(
295+
reader=self.delegate.trigger_manager,
296+
poll_interval=self.trigger_poll_interval,
297+
on_poll_attempt=on_poll_attempt,
298+
should_stop=should_stop,
311299
)
312300

313-
for task in pending:
314-
task.cancel()
315-
try:
316-
await task
317-
except asyncio.CancelledError:
318-
pass
301+
result = await poller.poll_trigger(trigger)
319302

320-
if term_task in done:
303+
if self._quit_requested:
321304
raise UiPathDebugQuitError("Debugging terminated during polling.")
305+
306+
return result

src/uipath/runtime/resumable/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Module for resumable runtime features."""
22

3+
from uipath.runtime.resumable.polling import TriggerPoller
34
from uipath.runtime.resumable.protocols import (
45
UiPathResumableStorageProtocol,
56
UiPathResumeTriggerCreatorProtocol,
@@ -20,4 +21,5 @@
2021
"UiPathResumeTrigger",
2122
"UiPathResumeTriggerType",
2223
"UiPathApiTrigger",
24+
"TriggerPoller",
2325
]

0 commit comments

Comments
 (0)