Skip to content

Commit 2330d8b

Browse files
feat: propagate errors from agent runtime to CAS
1 parent fde17bc commit 2330d8b

File tree

4 files changed

+132
-53
lines changed

4 files changed

+132
-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: 124 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,54 @@
2324

2425
logger = logging.getLogger(__name__)
2526

27+
class CASErrorId:
28+
LICENSING = "AGENT_LICENSING_CONSUMPTION_VALIDATION_FAILED"
29+
INCOMPLETE_RESPONSE = "AGENT_RESPONSE_IS_INCOMPLETE"
30+
INVALID_INPUT = "AGENT_INVALID_INPUT"
31+
DEFAULT_ERROR = "AGENT_RUNTIME_ERROR"
32+
33+
_DEFAULT_ERROR_MESSAGE = "An unexpected error has occurred."
34+
35+
# Error code mappings to CAS error IDs.
36+
_CAS_ERROR_ID_MAP = {
37+
"LICENSE_NOT_AVAILABLE": CASErrorId.LICENSING,
38+
"UNSUCCESSFUL_STOP_REASON": CASErrorId.INCOMPLETE_RESPONSE,
39+
"INVALID_INPUT_FILE_EXTENSION": CASErrorId.INVALID_INPUT,
40+
"MISSING_INPUT_FILE": CASErrorId.INVALID_INPUT,
41+
"INPUT_INVALID_JSON": CASErrorId.INVALID_INPUT,
42+
}
43+
44+
def _resolve_error_id(error: UiPathErrorContract) -> str:
45+
"""Map an error contract code to a CAS error ID."""
46+
if error.code:
47+
suffix = error.code.rsplit(".", 1)[-1]
48+
if suffix in _CAS_ERROR_ID_MAP:
49+
return _CAS_ERROR_ID_MAP[suffix]
50+
return error.code or CASErrorId.DEFAULT_ERROR
51+
52+
53+
def _extract_error_from_exception(e: Exception) -> tuple[str, str]:
54+
"""Extract error_id and user-facing message from an exception."""
55+
if isinstance(e, UiPathBaseRuntimeError):
56+
return _extract_error_from_contract(e.error_info)
57+
return CASErrorId.DEFAULT_ERROR, _DEFAULT_ERROR_MESSAGE
58+
59+
60+
def _extract_error_from_contract(
61+
error: UiPathErrorContract | None,
62+
) -> tuple[str, str]:
63+
"""Extract error_id and user-facing message from an error contract."""
64+
if not error:
65+
return CASErrorId.DEFAULT_ERROR, _DEFAULT_ERROR_MESSAGE
66+
error_id = _resolve_error_id(error)
67+
title = error.title or ""
68+
detail = error.detail.split("\n")[0] if error.detail else ""
69+
if title and detail:
70+
error_message = f"{title}. {detail}"
71+
else:
72+
error_message = title or detail or _DEFAULT_ERROR_MESSAGE
73+
return error_id, error_message
74+
2675

2776
class UiPathChatRuntime:
2877
"""Specialized runtime for chat mode that streams message events to a chat bridge."""
@@ -65,62 +114,86 @@ async def stream(
65114
options: UiPathStreamOptions | None = None,
66115
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
67116
"""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
117+
try:
118+
await self.chat_bridge.connect()
119+
120+
execution_completed = False
121+
current_input = input
122+
current_options = UiPathStreamOptions(
123+
resume=options.resume if options else False,
124+
breakpoints=options.breakpoints if options else None,
125+
)
126+
127+
while not execution_completed:
128+
async for event in self.delegate.stream(
129+
current_input, options=current_options
130+
):
131+
if isinstance(event, UiPathRuntimeMessageEvent):
132+
if event.payload:
133+
await self.chat_bridge.emit_message_event(event.payload)
134+
135+
if isinstance(event, UiPathRuntimeResult):
136+
runtime_result = event
137+
138+
if (
139+
runtime_result.status == UiPathRuntimeStatus.SUSPENDED
140+
and runtime_result.triggers
141+
):
142+
api_triggers = [
143+
t
144+
for t in runtime_result.triggers
145+
if t.trigger_type == UiPathResumeTriggerType.API
146+
]
147+
148+
if api_triggers:
149+
resume_map: dict[str, Any] = {}
150+
151+
for trigger in api_triggers:
152+
await self.chat_bridge.emit_interrupt_event(trigger)
153+
154+
resume_data = (
155+
await self.chat_bridge.wait_for_resume()
156+
)
157+
158+
assert trigger.interrupt_id is not None, (
159+
"Trigger interrupt_id cannot be None"
160+
)
161+
resume_map[trigger.interrupt_id] = resume_data
162+
163+
current_input = resume_map
164+
current_options.resume = True
165+
break
166+
else:
167+
# No API triggers - yield result and complete
168+
yield event
169+
execution_completed = True
170+
elif runtime_result.status == UiPathRuntimeStatus.FAULTED:
171+
await self._emit_error_event(
172+
*_extract_error_from_contract(runtime_result.error)
173+
)
174+
yield event
175+
execution_completed = True
114176
else:
115-
# No API triggers - yield result and complete
116177
yield event
117178
execution_completed = True
179+
await self.chat_bridge.emit_exchange_end_event()
118180
else:
119181
yield event
120-
execution_completed = True
121-
await self.chat_bridge.emit_exchange_end_event()
122-
else:
123-
yield event
182+
183+
except Exception as e:
184+
error_id, error_message = _extract_error_from_exception(e)
185+
await self._emit_error_event(error_id, error_message)
186+
raise
187+
188+
async def _emit_error_event(self, error_id: str, message: str) -> None:
189+
"""Emit an exchange error event to the chat bridge."""
190+
try:
191+
await self.chat_bridge.emit_exchange_error_event(
192+
error_id=error_id,
193+
message=message,
194+
)
195+
except Exception:
196+
logger.warning("Failed to emit exchange error event", exc_info=True)
124197

125198
async def get_schema(self) -> UiPathRuntimeSchema:
126199
"""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)