Skip to content

Commit 073714d

Browse files
feat: propagate errors from agent runtime to CAS
1 parent fde17bc commit 073714d

File tree

4 files changed

+137
-53
lines changed

4 files changed

+137
-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: 129 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,
@@ -24,6 +25,59 @@
2425
logger = logging.getLogger(__name__)
2526

2627

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

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

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