Skip to content

Commit 4f4730b

Browse files
feat: propagate errors from agent runtime to CAS
1 parent fde17bc commit 4f4730b

File tree

4 files changed

+110
-53
lines changed

4 files changed

+110
-53
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.9.3"
3+
version = "0.9.4"
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ async def emit_exchange_end_event(self) -> None:
4747
"""Send an exchange end event."""
4848
...
4949

50+
async def emit_exchange_error_event(
51+
self, error_id: str, message: str, details: Any | None = None
52+
) -> None:
53+
"""Emit an exchange error event."""
54+
...
55+
5056
async def wait_for_resume(self) -> dict[str, Any]:
5157
"""Wait for the interrupt_end event to be received."""
5258
...

src/uipath/runtime/chat/runtime.py

Lines changed: 102 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
UiPathStreamOptions,
1212
)
1313
from uipath.runtime.chat.protocol import UiPathChatProtocol
14+
from uipath.runtime.errors import UiPathBaseRuntimeError, UiPathErrorContract
1415
from uipath.runtime.events import (
1516
UiPathRuntimeEvent,
1617
UiPathRuntimeMessageEvent,
@@ -23,6 +24,32 @@
2324

2425
logger = logging.getLogger(__name__)
2526

27+
_DEFAULT_ERROR_ID = "AGENT_RUNTIME_ERROR"
28+
_DEFAULT_ERROR_MESSAGE = "An unexpected error occurred."
29+
30+
31+
def _extract_error_from_exception(e: Exception) -> tuple[str, str]:
32+
"""Extract error_id and user-facing message from an exception."""
33+
if isinstance(e, UiPathBaseRuntimeError):
34+
return _extract_error_from_contract(e.error_info)
35+
return _DEFAULT_ERROR_ID, _DEFAULT_ERROR_MESSAGE
36+
37+
38+
def _extract_error_from_contract(
39+
error: UiPathErrorContract | None,
40+
) -> tuple[str, str]:
41+
"""Extract error_id and user-facing message from an error contract."""
42+
if not error:
43+
return _DEFAULT_ERROR_ID, _DEFAULT_ERROR_MESSAGE
44+
error_id = error.code or _DEFAULT_ERROR_ID
45+
title = error.title or ""
46+
detail = error.detail.split("\n")[0] if error.detail else ""
47+
if title and detail:
48+
error_message = f"{title}. {detail}"
49+
else:
50+
error_message = title or detail or _DEFAULT_ERROR_MESSAGE
51+
return error_id, error_message
52+
2653

2754
class UiPathChatRuntime:
2855
"""Specialized runtime for chat mode that streams message events to a chat bridge."""
@@ -65,62 +92,86 @@ async def stream(
6592
options: UiPathStreamOptions | None = None,
6693
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
6794
"""Stream execution events with chat support."""
68-
await self.chat_bridge.connect()
69-
70-
execution_completed = False
71-
current_input = input
72-
current_options = UiPathStreamOptions(
73-
resume=options.resume if options else False,
74-
breakpoints=options.breakpoints if options else None,
75-
)
76-
77-
while not execution_completed:
78-
async for event in self.delegate.stream(
79-
current_input, options=current_options
80-
):
81-
if isinstance(event, UiPathRuntimeMessageEvent):
82-
if event.payload:
83-
await self.chat_bridge.emit_message_event(event.payload)
84-
85-
if isinstance(event, UiPathRuntimeResult):
86-
runtime_result = event
87-
88-
if (
89-
runtime_result.status == UiPathRuntimeStatus.SUSPENDED
90-
and runtime_result.triggers
91-
):
92-
api_triggers = [
93-
t
94-
for t in runtime_result.triggers
95-
if t.trigger_type == UiPathResumeTriggerType.API
96-
]
97-
98-
if api_triggers:
99-
resume_map: dict[str, Any] = {}
100-
101-
for trigger in api_triggers:
102-
await self.chat_bridge.emit_interrupt_event(trigger)
103-
104-
resume_data = await self.chat_bridge.wait_for_resume()
105-
106-
assert trigger.interrupt_id is not None, (
107-
"Trigger interrupt_id cannot be None"
108-
)
109-
resume_map[trigger.interrupt_id] = resume_data
110-
111-
current_input = resume_map
112-
current_options.resume = True
113-
break
95+
try:
96+
await self.chat_bridge.connect()
97+
98+
execution_completed = False
99+
current_input = input
100+
current_options = UiPathStreamOptions(
101+
resume=options.resume if options else False,
102+
breakpoints=options.breakpoints if options else None,
103+
)
104+
105+
while not execution_completed:
106+
async for event in self.delegate.stream(
107+
current_input, options=current_options
108+
):
109+
if isinstance(event, UiPathRuntimeMessageEvent):
110+
if event.payload:
111+
await self.chat_bridge.emit_message_event(event.payload)
112+
113+
if isinstance(event, UiPathRuntimeResult):
114+
runtime_result = event
115+
116+
if (
117+
runtime_result.status == UiPathRuntimeStatus.SUSPENDED
118+
and runtime_result.triggers
119+
):
120+
api_triggers = [
121+
t
122+
for t in runtime_result.triggers
123+
if t.trigger_type == UiPathResumeTriggerType.API
124+
]
125+
126+
if api_triggers:
127+
resume_map: dict[str, Any] = {}
128+
129+
for trigger in api_triggers:
130+
await self.chat_bridge.emit_interrupt_event(trigger)
131+
132+
resume_data = (
133+
await self.chat_bridge.wait_for_resume()
134+
)
135+
136+
assert trigger.interrupt_id is not None, (
137+
"Trigger interrupt_id cannot be None"
138+
)
139+
resume_map[trigger.interrupt_id] = resume_data
140+
141+
current_input = resume_map
142+
current_options.resume = True
143+
break
144+
else:
145+
# No API triggers - yield result and complete
146+
yield event
147+
execution_completed = True
148+
elif runtime_result.status == UiPathRuntimeStatus.FAULTED:
149+
await self._emit_error_event(
150+
*_extract_error_from_contract(runtime_result.error)
151+
)
152+
yield event
153+
execution_completed = True
114154
else:
115-
# No API triggers - yield result and complete
116155
yield event
117156
execution_completed = True
157+
await self.chat_bridge.emit_exchange_end_event()
118158
else:
119159
yield event
120-
execution_completed = True
121-
await self.chat_bridge.emit_exchange_end_event()
122-
else:
123-
yield event
160+
161+
except Exception as e:
162+
error_id, error_message = _extract_error_from_exception(e)
163+
await self._emit_error_event(error_id, error_message)
164+
raise
165+
166+
async def _emit_error_event(self, error_id: str, message: str) -> None:
167+
"""Emit an exchange error event to the chat bridge."""
168+
try:
169+
await self.chat_bridge.emit_exchange_error_event(
170+
error_id=error_id,
171+
message=message,
172+
)
173+
except Exception:
174+
logger.warning("Failed to emit exchange error event", exc_info=True)
124175

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

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)