Skip to content

Commit 464caaa

Browse files
feat: conversational agent support
1 parent a51f664 commit 464caaa

File tree

11 files changed

+1208
-31
lines changed

11 files changed

+1208
-31
lines changed

src/uipath/_cli/_chat/_bridge.py

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Chat bridge implementations for conversational agents."""
22

33
import asyncio
4+
import json
45
import logging
56
import os
67
import uuid
7-
from typing import Any
8+
from typing import Any, Dict
89
from urllib.parse import urlparse
910

1011
import socketio # type: ignore[import-untyped]
@@ -57,6 +58,10 @@ def __init__(
5758
self._client: AsyncClient | None = None
5859
self._connected_event = asyncio.Event()
5960

61+
# Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from
62+
# interrupting the debugging session. Events will be logged instead of being sent.
63+
self._websocket_disabled = os.environ.get("CAS_WEBSOCKET_DISABLED") == "true"
64+
6065
async def connect(self, timeout: float = 10.0) -> None:
6166
"""Establish WebSocket connection to the server.
6267
@@ -86,37 +91,43 @@ async def connect(self, timeout: float = 10.0) -> None:
8691
self._client.on("connect", self._handle_connect)
8792
self._client.on("disconnect", self._handle_disconnect)
8893
self._client.on("connect_error", self._handle_connect_error)
94+
self._client.on("ConversationEvent", self._handle_conversation_event)
8995

9096
self._connected_event.clear()
9197

92-
try:
93-
# Attempt to connect with timeout
94-
await asyncio.wait_for(
95-
self._client.connect(
96-
url=self.websocket_url,
97-
socketio_path=self.websocket_path,
98-
headers=self.headers,
99-
auth=self.auth,
100-
transports=["websocket"],
101-
),
102-
timeout=timeout,
98+
if self._websocket_disabled:
99+
logger.warning(
100+
"SocketIOChatBridge is in debug mode. Not connecting websocket."
103101
)
102+
else:
103+
try:
104+
# Attempt to connect with timeout
105+
await asyncio.wait_for(
106+
self._client.connect(
107+
url=self.websocket_url,
108+
socketio_path=self.websocket_path,
109+
headers=self.headers,
110+
auth=self.auth,
111+
transports=["websocket"],
112+
),
113+
timeout=timeout,
114+
)
104115

105-
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
116+
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
106117

107-
except asyncio.TimeoutError as e:
108-
error_message = (
109-
f"Failed to connect to WebSocket server within {timeout}s timeout"
110-
)
111-
logger.error(error_message)
112-
await self._cleanup_client()
113-
raise RuntimeError(error_message) from e
118+
except asyncio.TimeoutError as e:
119+
error_message = (
120+
f"Failed to connect to WebSocket server within {timeout}s timeout"
121+
)
122+
logger.error(error_message)
123+
await self._cleanup_client()
124+
raise RuntimeError(error_message) from e
114125

115-
except Exception as e:
116-
error_message = f"Failed to connect to WebSocket server: {e}"
117-
logger.error(error_message)
118-
await self._cleanup_client()
119-
raise RuntimeError(error_message) from e
126+
except Exception as e:
127+
error_message = f"Failed to connect to WebSocket server: {e}"
128+
logger.error(error_message)
129+
await self._cleanup_client()
130+
raise RuntimeError(error_message) from e
120131

121132
async def disconnect(self) -> None:
122133
"""Close the WebSocket connection gracefully.
@@ -149,7 +160,7 @@ async def emit_message_event(
149160
if self._client is None:
150161
raise RuntimeError("WebSocket client not connected. Call connect() first.")
151162

152-
if not self._connected_event.is_set():
163+
if not self._connected_event.is_set() and not self._websocket_disabled:
153164
raise RuntimeError("WebSocket client not in connected state")
154165

155166
try:
@@ -166,7 +177,12 @@ async def emit_message_event(
166177
mode="json", exclude_none=True, by_alias=True
167178
)
168179

169-
await self._client.emit("ConversationEvent", event_data)
180+
if self._websocket_disabled:
181+
logger.info(
182+
f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}"
183+
)
184+
else:
185+
await self._client.emit("ConversationEvent", event_data)
170186

171187
# Store the current message ID, used for emitting interrupt events.
172188
self._current_message_id = message_event.message_id
@@ -184,7 +200,7 @@ async def emit_exchange_end_event(self) -> None:
184200
if self._client is None:
185201
raise RuntimeError("WebSocket client not connected. Call connect() first.")
186202

187-
if not self._connected_event.is_set():
203+
if not self._connected_event.is_set() and not self._websocket_disabled:
188204
raise RuntimeError("WebSocket client not in connected state")
189205

190206
try:
@@ -200,7 +216,12 @@ async def emit_exchange_end_event(self) -> None:
200216
mode="json", exclude_none=True, by_alias=True
201217
)
202218

203-
await self._client.emit("ConversationEvent", event_data)
219+
if self._websocket_disabled:
220+
logger.info(
221+
f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}"
222+
)
223+
else:
224+
await self._client.emit("ConversationEvent", event_data)
204225

205226
except Exception as e:
206227
logger.error(f"Error sending conversation event to WebSocket: {e}")
@@ -230,7 +251,12 @@ async def emit_interrupt_event(self, runtime_result: UiPathRuntimeResult):
230251
event_data = interrupt_event.model_dump(
231252
mode="json", exclude_none=True, by_alias=True
232253
)
233-
await self._client.emit("ConversationEvent", event_data)
254+
if self._websocket_disabled:
255+
logger.info(
256+
f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}"
257+
)
258+
else:
259+
await self._client.emit("ConversationEvent", event_data)
234260
except Exception as e:
235261
logger.warning(f"Error sending interrupt event: {e}")
236262

@@ -265,6 +291,14 @@ async def _handle_connect_error(self, data: Any) -> None:
265291
"""Handle connection error event."""
266292
logger.error(f"WebSocket connection error: {data}")
267293

294+
async def _handle_conversation_event(
295+
self, event: Dict[str, Any], _sid: str
296+
) -> None:
297+
"""Handle received ConversationEvent events."""
298+
error_event = event.get("conversationError")
299+
if error_event:
300+
logger.error(f"Conversation error: {json.dumps(error_event)}")
301+
268302
async def _cleanup_client(self) -> None:
269303
"""Clean up client resources."""
270304
self._connected_event.clear()
@@ -315,6 +349,13 @@ def get_chat_bridge(
315349
websocket_url = f"wss://{host}?conversationId={context.conversation_id}"
316350
websocket_path = "autopilotforeveryone_/websocket_/socket.io"
317351

352+
if os.environ.get("CAS_WEBSOCKET_HOST"):
353+
websocket_url = f"ws://{os.environ.get('CAS_WEBSOCKET_HOST')}?conversationId={context.conversation_id}"
354+
websocket_path = "/socket.io"
355+
logger.warning(
356+
f"CAS_WEBSOCKET_HOST is set. Using websocket_url '{websocket_url}{websocket_path}'."
357+
)
358+
318359
# Build headers from context
319360
headers = {
320361
"Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}",

src/uipath/_cli/cli_run.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@
6969
default=5678,
7070
help="Port for the debug server (default: 5678)",
7171
)
72+
@click.option(
73+
"--keep-state-file",
74+
is_flag=True,
75+
help="Keep the state file even when not resuming and no job id is provided",
76+
)
7277
def run(
7378
entrypoint: str | None,
7479
input: str | None,
@@ -79,6 +84,7 @@ def run(
7984
trace_file: str | None,
8085
debug: bool,
8186
debug_port: int,
87+
keep_state_file: bool,
8288
) -> None:
8389
"""Execute the project."""
8490
input_file = file or input_file
@@ -147,6 +153,7 @@ async def execute() -> None:
147153
resume=resume,
148154
command="run",
149155
trace_manager=trace_manager,
156+
keep_state_file=keep_state_file,
150157
)
151158

152159
if ctx.trace_file:

src/uipath/agent/models/agent.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,15 @@ class AgentDefinition(BaseModel):
803803
validate_by_name=True, validate_by_alias=True, extra="allow"
804804
)
805805

806+
@property
807+
def is_conversational(self) -> bool:
808+
"""Checks the settings.engine property to determine if the agent is conversational."""
809+
if hasattr(self, "metadata") and self.metadata:
810+
metadata = self.metadata
811+
if hasattr(metadata, "is_conversational"):
812+
return metadata.is_conversational
813+
return False
814+
806815
@staticmethod
807816
def _normalize_guardrails(v: Dict[str, Any]) -> None:
808817
guards = v.get("guardrails")

src/uipath/agent/react/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
This module includes UiPath ReAct Agent Loop constructs such as prompts, tools
44
"""
55

6+
from .conversational_prompts import (
7+
PromptUserSettings,
8+
generate_conversational_agent_system_prompt,
9+
)
610
from .prompts import AGENT_SYSTEM_PROMPT_TEMPLATE
711
from .tools import (
812
END_EXECUTION_TOOL,
@@ -19,4 +23,6 @@
1923
"RAISE_ERROR_TOOL",
2024
"EndExecutionToolSchemaModel",
2125
"RaiseErrorToolSchemaModel",
26+
"PromptUserSettings",
27+
"generate_conversational_agent_system_prompt",
2228
]

0 commit comments

Comments
 (0)