From da986de4a3e0c58c55caf920608509dd78f13f4f Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Thu, 4 Dec 2025 17:14:03 +0200 Subject: [PATCH] feat: add chat protocol implementation --- pyproject.toml | 6 +- src/uipath/_cli/_chat/_protocol.py | 279 +++++++++++++++++++++++++++++ src/uipath/_cli/cli_run.py | 1 + uv.lock | 99 ++++++---- 4 files changed, 345 insertions(+), 40 deletions(-) create mode 100644 src/uipath/_cli/_chat/_protocol.py diff --git a/pyproject.toml b/pyproject.toml index 44f83ca60..c99a28601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath" -version = "2.2.21" +version = "2.2.22" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-runtime>=0.2.0, <0.3.0", + "uipath-runtime>=0.2.2, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", @@ -15,9 +15,9 @@ dependencies = [ "rich>=14.2.0", "truststore>=0.10.1", "mockito>=1.5.4", - "hydra-core>=1.3.2", "pydantic-function-models>=0.1.11", "pysignalr==1.3.0", + "python-socketio>=5.15.0, <6.0.0", "coverage>=7.8.2", "mermaid-builder==0.0.3", ] diff --git a/src/uipath/_cli/_chat/_protocol.py b/src/uipath/_cli/_chat/_protocol.py new file mode 100644 index 000000000..8ffed568b --- /dev/null +++ b/src/uipath/_cli/_chat/_protocol.py @@ -0,0 +1,279 @@ +"""Chat bridge implementations for conversational agents.""" + +import asyncio +import logging +import os +from typing import Any +from urllib.parse import urlparse + +import socketio # type: ignore[import-untyped] +from socketio import AsyncClient +from uipath.core.chat import ( + UiPathConversationEvent, + UiPathConversationExchangeEndEvent, + UiPathConversationExchangeEvent, +) +from uipath.runtime.chat import UiPathChatProtocol +from uipath.runtime.context import UiPathRuntimeContext + +logger = logging.getLogger(__name__) + + +class WebSocketChatBridge: + """WebSocket-based chat bridge for streaming conversational events to CAS. + + Implements UiPathChatBridgeProtocol using python-socketio library. + """ + + def __init__( + self, + websocket_url: str, + conversation_id: str, + exchange_id: str, + headers: dict[str, str], + auth: dict[str, Any] | None = None, + ): + """Initialize the WebSocket chat bridge. + + Args: + websocket_url: The WebSocket server URL to connect to + conversation_id: The conversation ID for this session + exchange_id: The exchange ID for this session + headers: HTTP headers to send during connection + auth: Optional authentication data to send during connection + """ + self.websocket_url = websocket_url + self.conversation_id = conversation_id + self.exchange_id = exchange_id + self.auth = auth + self.headers = headers + self._client: AsyncClient | None = None + self._connected_event = asyncio.Event() + + async def connect(self, timeout: float = 10.0) -> None: + """Establish WebSocket connection to the server. + + Args: + timeout: Connection timeout in seconds (default: 10.0) + + Raises: + RuntimeError: If connection fails or times out + + Example: + ```python + manager = WebSocketManager("http://localhost:3000") + await manager.connect() + ``` + """ + if self._client is not None: + logger.warning("WebSocket client already connected") + return + + # Create new SocketIO client + self._client = socketio.AsyncClient( + logger=logger, + engineio_logger=logger, + ) + + # Register connection event handlers + self._client.on("connect", self._handle_connect) + self._client.on("disconnect", self._handle_disconnect) + self._client.on("connect_error", self._handle_connect_error) + + # Clear connection event + self._connected_event.clear() + + try: + # Attempt to connect with timeout + logger.info(f"Connecting to WebSocket server: {self.websocket_url}") + + await asyncio.wait_for( + self._client.connect( + url=self.websocket_url, + headers=self.headers, + auth=self.auth, + transports=["websocket"], + ), + timeout=timeout, + ) + + # Wait for connection confirmation + await asyncio.wait_for(self._connected_event.wait(), timeout=timeout) + + logger.info("WebSocket connection established successfully") + + except asyncio.TimeoutError as e: + error_message = ( + f"Failed to connect to WebSocket server within {timeout}s timeout" + ) + logger.error(error_message) + await self._cleanup_client() + raise RuntimeError(error_message) from e + + except Exception as e: + error_message = f"Failed to connect to WebSocket server: {e}" + logger.error(error_message) + await self._cleanup_client() + raise RuntimeError(error_message) from e + + async def disconnect(self) -> None: + """Close the WebSocket connection gracefully. + + Sends an exchange end event before disconnecting to signal that the + exchange is complete. Uses stored conversation/exchange IDs. + """ + if self._client is None: + logger.warning("WebSocket client not connected") + return + + # Send exchange end event using stored IDs + if self._client and self._connected_event.is_set(): + try: + end_event = UiPathConversationEvent( + conversation_id=self.conversation_id, + exchange=UiPathConversationExchangeEvent( + exchange_id=self.exchange_id, + end=UiPathConversationExchangeEndEvent(), + ), + ) + event_data = end_event.model_dump( + mode="json", exclude_none=True, by_alias=True + ) + await self._client.emit("ConversationEvent", event_data) + logger.info("Exchange end event sent") + except Exception as e: + logger.warning(f"Error sending exchange end event: {e}") + + try: + logger.info("Disconnecting from WebSocket server") + await self._client.disconnect() + logger.info("WebSocket disconnected successfully") + except Exception as e: + logger.error(f"Error during WebSocket disconnect: {e}") + finally: + await self._cleanup_client() + + async def emit_message_event(self, message_event: Any) -> None: + """Wrap and send a message event to the WebSocket server. + + Args: + message_event: UiPathConversationMessageEvent to wrap and send + + Raises: + RuntimeError: If client is not connected + """ + if self._client is None: + raise RuntimeError("WebSocket client not connected. Call connect() first.") + + if not self._connected_event.is_set(): + raise RuntimeError("WebSocket client not in connected state") + + try: + # Wrap message event with conversation/exchange IDs + wrapped_event = UiPathConversationEvent( + conversation_id=self.conversation_id, + exchange=UiPathConversationExchangeEvent( + exchange_id=self.exchange_id, + message=message_event, + ), + ) + + event_data = wrapped_event.model_dump( + mode="json", exclude_none=True, by_alias=True + ) + + logger.debug("Sending conversation event to WebSocket") + await self._client.emit("ConversationEvent", event_data) + logger.debug("Conversation event sent successfully") + + except Exception as e: + logger.error(f"Error sending conversation event to WebSocket: {e}") + raise RuntimeError(f"Failed to send conversation event: {e}") from e + + @property + def is_connected(self) -> bool: + """Check if the WebSocket is currently connected. + + Returns: + True if connected, False otherwise + """ + return self._client is not None and self._connected_event.is_set() + + async def _handle_connect(self) -> None: + """Handle successful connection event.""" + logger.info("WebSocket connection established") + self._connected_event.set() + + async def _handle_disconnect(self) -> None: + """Handle disconnection event.""" + logger.info("WebSocket connection closed") + self._connected_event.clear() + + async def _handle_connect_error(self, data: Any) -> None: + """Handle connection error event.""" + logger.error(f"WebSocket connection error: {data}") + + async def _cleanup_client(self) -> None: + """Clean up client resources.""" + self._connected_event.clear() + self._client = None + + +def get_chat_bridge( + context: UiPathRuntimeContext, + conversation_id: str, + exchange_id: str, +) -> UiPathChatProtocol: + """Factory to get WebSocket chat bridge for conversational agents. + + Args: + context: The runtime context containing environment configuration + conversation_id: The conversation ID for this session + exchange_id: The exchange ID for this session + + Returns: + WebSocketChatBridge instance configured for CAS + + Raises: + RuntimeError: If UIPATH_URL is not set or invalid + + Example: + ```python + bridge = get_chat_bridge(context, "conv-123", "exch-456") + await bridge.connect() + await bridge.emit_message_event(message_event) + await bridge.disconnect(conversation_id, exchange_id) + ``` + """ + # Extract host from UIPATH_URL + base_url = os.environ.get("UIPATH_URL") + if not base_url: + raise RuntimeError( + "UIPATH_URL environment variable required for conversational mode" + ) + + parsed = urlparse(base_url) + if not parsed.netloc: + raise RuntimeError(f"Invalid UIPATH_URL format: {base_url}") + + host = parsed.netloc + + # Construct WebSocket URL for CAS + websocket_url = f"wss://{host}/autopilotforeveryone_/websocket_/socket.io?conversationId={conversation_id}" + + # Build headers from context + headers = { + "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", + "X-UiPath-Internal-TenantId": context.tenant_id + or os.environ.get("UIPATH_TENANT_ID", ""), + "X-UiPath-Internal-AccountId": context.org_id + or os.environ.get("UIPATH_ORGANIZATION_ID", ""), + "X-UiPath-ConversationId": conversation_id, + } + + return WebSocketChatBridge( + websocket_url=websocket_url, + conversation_id=conversation_id, + exchange_id=exchange_id, + headers=headers, + ) diff --git a/src/uipath/_cli/cli_run.py b/src/uipath/_cli/cli_run.py index 2a378243c..9c253a93e 100644 --- a/src/uipath/_cli/cli_run.py +++ b/src/uipath/_cli/cli_run.py @@ -162,6 +162,7 @@ async def execute() -> None: runtime = await factory.new_runtime( entrypoint, ctx.job_id or "default" ) + if ctx.job_id: trace_manager.add_span_exporter(LlmOpsHttpExporter()) ctx.result = await execute_runtime(ctx, runtime) diff --git a/uv.lock b/uv.lock index 205beecfc..128e36fbf 100644 --- a/uv.lock +++ b/uv.lock @@ -135,12 +135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "antlr4-python3-runtime" -version = "4.9.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } - [[package]] name = "anyio" version = "4.12.0" @@ -214,6 +208,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + [[package]] name = "cairocffi" version = "1.7.1" @@ -722,20 +725,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "hydra-core" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "antlr4-python3-runtime" }, - { name = "omegaconf" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/8e/07e42bc434a847154083b315779b0a81d567154504624e181caf2c71cd98/hydra-core-1.3.2.tar.gz", hash = "sha256:8a878ed67216997c3e9d88a8e72e7b4767e81af37afb4ea3334b269a4390a824", size = 3263494, upload-time = "2023-02-23T18:33:43.03Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/50/e0edd38dcd63fb26a8547f13d28f7a008bc4a3fd4eb4ff030673f22ad41a/hydra_core-1.3.2-py3-none-any.whl", hash = "sha256:fa0238a9e31df3373b35b0bfb672c34cc92718d21f81311d8996a16de1141d8b", size = 154547, upload-time = "2023-02-23T18:33:40.801Z" }, -] - [[package]] name = "identify" version = "2.6.15" @@ -1432,19 +1421,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] -[[package]] -name = "omegaconf" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "antlr4-python3-runtime" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.39.0" @@ -2127,6 +2103,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-engineio" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/d8/63e5535ab21dc4998ba1cfe13690ccf122883a38f025dca24d6e56c05eba/python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a", size = 91910, upload-time = "2025-09-28T06:31:36.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f0/c5aa0a69fd9326f013110653543f36ece4913c17921f3e1dbd78e1b423ee/python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1", size = 59637, upload-time = "2025-09-28T06:31:35.354Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/a8/5f7c805dd6d0d6cba91d3ea215b4b88889d1b99b71a53c932629daba53f1/python_socketio-5.15.0.tar.gz", hash = "sha256:d0403ababb59aa12fd5adcfc933a821113f27bd77761bc1c54aad2e3191a9b69", size = 126439, upload-time = "2025-11-22T18:50:21.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/fa/1ef2f8537272a2f383d72b9301c3ef66a49710b3bb7dcb2bd138cf2920d1/python_socketio-5.15.0-py3-none-any.whl", hash = "sha256:e93363102f4da6d8e7a8872bf4908b866c40f070e716aa27132891e643e2687c", size = 79451, upload-time = "2025-11-22T18:50:19.416Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2270,6 +2271,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/c7/6c818dcac06844608244855753a0afb367365661b40fe6b3288bc26726a6/rust_just-1.43.1-py3-none-win_amd64.whl", hash = "sha256:28f0d898d3e04846348277a4d47231c949398102c2f6f452f52ba6506c9c7572", size = 1731800, upload-time = "2025-11-19T07:49:17.344Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2464,13 +2477,12 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.21" +version = "2.2.22" source = { editable = "." } dependencies = [ { name = "click" }, { name = "coverage" }, { name = "httpx" }, - { name = "hydra-core" }, { name = "mermaid-builder" }, { name = "mockito" }, { name = "pathlib" }, @@ -2478,6 +2490,7 @@ dependencies = [ { name = "pyjwt" }, { name = "pysignalr" }, { name = "python-dotenv" }, + { name = "python-socketio" }, { name = "rich" }, { name = "tenacity" }, { name = "truststore" }, @@ -2516,7 +2529,6 @@ requires-dist = [ { name = "click", specifier = ">=8.3.1" }, { name = "coverage", specifier = ">=7.8.2" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "hydra-core", specifier = ">=1.3.2" }, { name = "mermaid-builder", specifier = "==0.0.3" }, { name = "mockito", specifier = ">=1.5.4" }, { name = "pathlib", specifier = ">=1.0.1" }, @@ -2524,10 +2536,11 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.10.1" }, { name = "pysignalr", specifier = "==1.3.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "python-socketio", specifier = ">=5.15.0,<6.0.0" }, { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-runtime", specifier = ">=0.2.0,<0.3.0" }, + { name = "uipath-runtime", specifier = ">=0.2.2,<0.3.0" }, ] [package.metadata.requires-dev] @@ -2752,6 +2765,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "yarl" version = "1.22.0"