Skip to content

Commit fa608c1

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

File tree

4 files changed

+150
-53
lines changed

4 files changed

+150
-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: 142 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,72 @@
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+
MAX_STEPS_REACHED = "AGENT_MAXIMUM_SEQUENTIAL_STEPS_REACHED"
34+
INVALID_INPUT = "AGENT_INVALID_INPUT"
35+
DEFAULT_ERROR = "AGENT_RUNTIME_ERROR"
36+
37+
38+
# User-facing messages for each CAS error ID, matching the Temporal backend.
39+
_CAS_ERROR_MESSAGES = {
40+
CASErrorId.LICENSING: "Your action could not be completed. You've used all your units for this period. Please contact your administrator to add more units or wait until your allowance replenishes, then try again.",
41+
CASErrorId.INCOMPLETE_RESPONSE: "Could not obtain a full response from the model through streamed completion call.",
42+
CASErrorId.MAX_STEPS_REACHED: "Maximum number of sequential steps reached. You may send a new message to tell the agent to continue.",
43+
CASErrorId.DEFAULT_ERROR: "An unexpected error has occurred.",
44+
}
45+
46+
# Error code suffix mappings to CAS error IDs.
47+
_CAS_ERROR_ID_MAP = {
48+
"LICENSE_NOT_AVAILABLE": CASErrorId.LICENSING,
49+
"UNSUCCESSFUL_STOP_REASON": CASErrorId.INCOMPLETE_RESPONSE,
50+
"TERMINATION_MAX_ITERATIONS": CASErrorId.MAX_STEPS_REACHED,
51+
"INVALID_INPUT_FILE_EXTENSION": CASErrorId.INVALID_INPUT,
52+
"MISSING_INPUT_FILE": CASErrorId.INVALID_INPUT,
53+
"INPUT_INVALID_JSON": CASErrorId.INVALID_INPUT,
54+
}
55+
56+
57+
def _build_error_message(error: UiPathErrorContract) -> str:
58+
"""Build a user-facing message from the error contract's title and detail."""
59+
title = error.title or ""
60+
detail = error.detail.split("\n")[0] if error.detail else ""
61+
if title and detail:
62+
return f"{title}. {detail}"
63+
return title or detail or _CAS_ERROR_MESSAGES[CASErrorId.DEFAULT_ERROR]
64+
65+
66+
def _resolve_cas_error(error: UiPathErrorContract) -> tuple[str, str]:
67+
"""Map an error contract to a CAS error ID and user-facing message."""
68+
error_id = CASErrorId.DEFAULT_ERROR
69+
if error.code:
70+
suffix = error.code.rsplit(".", 1)[-1]
71+
if suffix in _CAS_ERROR_ID_MAP:
72+
error_id = _CAS_ERROR_ID_MAP[suffix]
73+
# Use hardcoded message if available, otherwise build from the error contract.
74+
message = _CAS_ERROR_MESSAGES.get(error_id) or _build_error_message(error)
75+
return error_id, message
76+
77+
78+
def _extract_error_from_exception(e: Exception) -> tuple[str, str]:
79+
"""Extract error_id and user-facing message from an exception."""
80+
if isinstance(e, UiPathBaseRuntimeError):
81+
return _extract_error_from_contract(e.error_info)
82+
return CASErrorId.DEFAULT_ERROR, _CAS_ERROR_MESSAGES[CASErrorId.DEFAULT_ERROR]
83+
84+
85+
def _extract_error_from_contract(
86+
error: UiPathErrorContract | None,
87+
) -> tuple[str, str]:
88+
"""Extract error_id and user-facing message from an error contract."""
89+
if not error:
90+
return CASErrorId.DEFAULT_ERROR, _CAS_ERROR_MESSAGES[CASErrorId.DEFAULT_ERROR]
91+
return _resolve_cas_error(error)
92+
93+
2794
class UiPathChatRuntime:
2895
"""Specialized runtime for chat mode that streams message events to a chat bridge."""
2996

@@ -65,62 +132,86 @@ async def stream(
65132
options: UiPathStreamOptions | None = None,
66133
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
67134
"""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
135+
try:
136+
await self.chat_bridge.connect()
137+
138+
execution_completed = False
139+
current_input = input
140+
current_options = UiPathStreamOptions(
141+
resume=options.resume if options else False,
142+
breakpoints=options.breakpoints if options else None,
143+
)
144+
145+
while not execution_completed:
146+
async for event in self.delegate.stream(
147+
current_input, options=current_options
148+
):
149+
if isinstance(event, UiPathRuntimeMessageEvent):
150+
if event.payload:
151+
await self.chat_bridge.emit_message_event(event.payload)
152+
153+
if isinstance(event, UiPathRuntimeResult):
154+
runtime_result = event
155+
156+
if (
157+
runtime_result.status == UiPathRuntimeStatus.SUSPENDED
158+
and runtime_result.triggers
159+
):
160+
api_triggers = [
161+
t
162+
for t in runtime_result.triggers
163+
if t.trigger_type == UiPathResumeTriggerType.API
164+
]
165+
166+
if api_triggers:
167+
resume_map: dict[str, Any] = {}
168+
169+
for trigger in api_triggers:
170+
await self.chat_bridge.emit_interrupt_event(trigger)
171+
172+
resume_data = (
173+
await self.chat_bridge.wait_for_resume()
174+
)
175+
176+
assert trigger.interrupt_id is not None, (
177+
"Trigger interrupt_id cannot be None"
178+
)
179+
resume_map[trigger.interrupt_id] = resume_data
180+
181+
current_input = resume_map
182+
current_options.resume = True
183+
break
184+
else:
185+
# No API triggers - yield result and complete
186+
yield event
187+
execution_completed = True
188+
elif runtime_result.status == UiPathRuntimeStatus.FAULTED:
189+
await self._emit_error_event(
190+
*_extract_error_from_contract(runtime_result.error)
191+
)
192+
yield event
193+
execution_completed = True
114194
else:
115-
# No API triggers - yield result and complete
116195
yield event
117196
execution_completed = True
197+
await self.chat_bridge.emit_exchange_end_event()
118198
else:
119199
yield event
120-
execution_completed = True
121-
await self.chat_bridge.emit_exchange_end_event()
122-
else:
123-
yield event
200+
201+
except Exception as e:
202+
error_id, error_message = _extract_error_from_exception(e)
203+
await self._emit_error_event(error_id, error_message)
204+
raise
205+
206+
async def _emit_error_event(self, error_id: str, message: str) -> None:
207+
"""Emit an exchange error event to the chat bridge."""
208+
try:
209+
await self.chat_bridge.emit_exchange_error_event(
210+
error_id=error_id,
211+
message=message,
212+
)
213+
except Exception:
214+
logger.warning("Failed to emit exchange error event", exc_info=True)
124215

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