Skip to content

Commit 6182e6c

Browse files
feat: propagate errors from agent runtime to CAS
1 parent fde17bc commit 6182e6c

4 files changed

Lines changed: 134 additions & 53 deletions

File tree

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: 126 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,56 @@
2324

2425
logger = logging.getLogger(__name__)
2526

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

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

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