|
11 | 11 | UiPathStreamOptions, |
12 | 12 | ) |
13 | 13 | from uipath.runtime.chat.protocol import UiPathChatProtocol |
| 14 | +from uipath.runtime.errors import UiPathBaseRuntimeError, UiPathErrorContract |
14 | 15 | from uipath.runtime.events import ( |
15 | 16 | UiPathRuntimeEvent, |
16 | 17 | UiPathRuntimeMessageEvent, |
|
24 | 25 | logger = logging.getLogger(__name__) |
25 | 26 |
|
26 | 27 |
|
| 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 | + |
27 | 94 | class UiPathChatRuntime: |
28 | 95 | """Specialized runtime for chat mode that streams message events to a chat bridge.""" |
29 | 96 |
|
@@ -65,62 +132,86 @@ async def stream( |
65 | 132 | options: UiPathStreamOptions | None = None, |
66 | 133 | ) -> AsyncGenerator[UiPathRuntimeEvent, None]: |
67 | 134 | """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 |
114 | 194 | else: |
115 | | - # No API triggers - yield result and complete |
116 | 195 | yield event |
117 | 196 | execution_completed = True |
| 197 | + await self.chat_bridge.emit_exchange_end_event() |
118 | 198 | else: |
119 | 199 | 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) |
124 | 215 |
|
125 | 216 | async def get_schema(self) -> UiPathRuntimeSchema: |
126 | 217 | """Get schema from the delegate runtime.""" |
|
0 commit comments