Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[project]
name = "uipath"
version = "2.4.18"
version = "2.5"
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-core>=0.1.4, <0.2.0",
"uipath-runtime>=0.4.1, <0.5.0",
"uipath-runtime>=0.5, <0.6.0",
"click>=8.3.1",
"httpx>=0.28.1",
"pyjwt>=2.10.1",
Expand Down
99 changes: 70 additions & 29 deletions src/uipath/_cli/_chat/_bridge.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Chat bridge implementations for conversational agents."""

import asyncio
import json
import logging
import os
import uuid
Expand Down Expand Up @@ -55,6 +56,10 @@ def __init__(
self._client: Any | None = None
self._connected_event = asyncio.Event()

# Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from
# interrupting the debugging session. Events will be logged instead of being sent.
self._websocket_disabled = os.environ.get("CAS_WEBSOCKET_DISABLED") == "true"

async def connect(self, timeout: float = 10.0) -> None:
"""Establish WebSocket connection to the server.

Expand Down Expand Up @@ -87,37 +92,43 @@ async def connect(self, timeout: float = 10.0) -> None:
self._client.on("connect", self._handle_connect)
self._client.on("disconnect", self._handle_disconnect)
self._client.on("connect_error", self._handle_connect_error)
self._client.on("ConversationEvent", self._handle_conversation_event)

self._connected_event.clear()

try:
# Attempt to connect with timeout
await asyncio.wait_for(
self._client.connect(
url=self.websocket_url,
socketio_path=self.websocket_path,
headers=self.headers,
auth=self.auth,
transports=["websocket"],
),
timeout=timeout,
if self._websocket_disabled:
logger.warning(
"SocketIOChatBridge is in debug mode. Not connecting websocket."
)
else:
try:
# Attempt to connect with timeout
await asyncio.wait_for(
self._client.connect(
url=self.websocket_url,
socketio_path=self.websocket_path,
headers=self.headers,
auth=self.auth,
transports=["websocket"],
),
timeout=timeout,
)

await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)

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 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
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.
Expand Down Expand Up @@ -150,7 +161,7 @@ async def emit_message_event(
if self._client is None:
raise RuntimeError("WebSocket client not connected. Call connect() first.")

if not self._connected_event.is_set():
if not self._connected_event.is_set() and not self._websocket_disabled:
raise RuntimeError("WebSocket client not in connected state")

try:
Expand All @@ -167,7 +178,12 @@ async def emit_message_event(
mode="json", exclude_none=True, by_alias=True
)

await self._client.emit("ConversationEvent", event_data)
if self._websocket_disabled:
logger.info(
f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}"
)
else:
await self._client.emit("ConversationEvent", event_data)

# Store the current message ID, used for emitting interrupt events.
self._current_message_id = message_event.message_id
Expand All @@ -185,7 +201,7 @@ async def emit_exchange_end_event(self) -> None:
if self._client is None:
raise RuntimeError("WebSocket client not connected. Call connect() first.")

if not self._connected_event.is_set():
if not self._connected_event.is_set() and not self._websocket_disabled:
raise RuntimeError("WebSocket client not in connected state")

try:
Expand All @@ -201,7 +217,12 @@ async def emit_exchange_end_event(self) -> None:
mode="json", exclude_none=True, by_alias=True
)

await self._client.emit("ConversationEvent", event_data)
if self._websocket_disabled:
logger.info(
f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}"
)
else:
await self._client.emit("ConversationEvent", event_data)

except Exception as e:
logger.error(f"Error sending conversation event to WebSocket: {e}")
Expand Down Expand Up @@ -231,7 +252,12 @@ async def emit_interrupt_event(self, runtime_result: UiPathRuntimeResult):
event_data = interrupt_event.model_dump(
mode="json", exclude_none=True, by_alias=True
)
await self._client.emit("ConversationEvent", event_data)
if self._websocket_disabled:
logger.info(
f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}"
)
else:
await self._client.emit("ConversationEvent", event_data)
except Exception as e:
logger.warning(f"Error sending interrupt event: {e}")

Expand Down Expand Up @@ -266,6 +292,14 @@ async def _handle_connect_error(self, data: Any) -> None:
"""Handle connection error event."""
logger.error(f"WebSocket connection error: {data}")

async def _handle_conversation_event(
self, event: dict[str, Any], _sid: str
) -> None:
"""Handle received ConversationEvent events."""
error_event = event.get("conversationError")
if error_event:
logger.error(f"Conversation error: {json.dumps(error_event)}")

async def _cleanup_client(self) -> None:
"""Clean up client resources."""
self._connected_event.clear()
Expand Down Expand Up @@ -316,6 +350,13 @@ def get_chat_bridge(
websocket_url = f"wss://{host}?conversationId={context.conversation_id}"
websocket_path = "autopilotforeveryone_/websocket_/socket.io"

if os.environ.get("CAS_WEBSOCKET_HOST"):
websocket_url = f"ws://{os.environ.get('CAS_WEBSOCKET_HOST')}?conversationId={context.conversation_id}"
websocket_path = "/socket.io"
logger.warning(
f"CAS_WEBSOCKET_HOST is set. Using websocket_url '{websocket_url}{websocket_path}'."
)

# Build headers from context
headers = {
"Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}",
Expand Down
7 changes: 7 additions & 0 deletions src/uipath/_cli/cli_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
default=5678,
help="Port for the debug server (default: 5678)",
)
@click.option(
"--keep-state-file",
is_flag=True,
help="Keep the state file even when not resuming and no job id is provided",
)
Copy link
Member

@cristipufu cristipufu Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's replace this with:

@click.option(
    "--state-file",
    required=False,
    type=click.Path(exists=True),
    help="Full path to the state file (takes priority over default location)"
)

Trying to shoot two birds with one stone, if we have this specified, we just use it as an override and don't delete it. Updated the runtime PR as well https://github.com/UiPath/uipath-runtime-python/pull/65/changes

We need to update the pyproject.toml with the new runtime version range + increment the minor here as well

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now if I want to keep the file, I have to know and provide the path to it? This doesn't make sense to me... the file you want to use and if you want to keep it are separate concepts.

Copy link
Author

@mike-deem-uipath mike-deem-uipath Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is where the keep-state-file flag was used: https://github.com/UiPath/uipath-langchain-python/pull/395/changes#diff-7533e122e0f409dfcb61aa90e55e348bb528eac57d711599e88b198f38a467adR70. So this becomes self.context.state_file_path is not None. Why would one expect just setting that property to have the side effect of not deleting the file? And what if I want the file to be deleted AND I want to override the path?

Having an explicit keep-state-file flag avoids these unexpected side effects.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the state file pointer for something else, was thinking we can re-use the concept for this as well, and avoid adding another option.

If you insist, you can add the keep-state-file option back

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea behind my thoughts is that you either:

  • provide your own sqlite database, which we use to store the state
  • or we create a temporary one for the local run and clean-up afterwards

def run(
entrypoint: str | None,
input: str | None,
Expand All @@ -79,6 +84,7 @@ def run(
trace_file: str | None,
debug: bool,
debug_port: int,
keep_state_file: bool,
) -> None:
"""Execute the project."""
input_file = file or input_file
Expand Down Expand Up @@ -147,6 +153,7 @@ async def execute() -> None:
resume=resume,
command="run",
trace_manager=trace_manager,
keep_state_file=keep_state_file,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state_file_path=state_file

)

if ctx.trace_file:
Expand Down
9 changes: 9 additions & 0 deletions src/uipath/agent/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,15 @@ class AgentDefinition(BaseModel):
validate_by_name=True, validate_by_alias=True, extra="allow"
)

@property
def is_conversational(self) -> bool:
"""Checks the settings.engine property to determine if the agent is conversational."""
if hasattr(self, "metadata") and self.metadata:
metadata = self.metadata
if hasattr(metadata, "is_conversational"):
return metadata.is_conversational
return False

@staticmethod
def _normalize_guardrails(v: Dict[str, Any]) -> None:
guards = v.get("guardrails")
Expand Down
6 changes: 6 additions & 0 deletions src/uipath/agent/react/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
This module includes UiPath ReAct Agent Loop constructs such as prompts, tools
"""

from .conversational_prompts import (
PromptUserSettings,
get_chat_system_prompt,
)
from .prompts import AGENT_SYSTEM_PROMPT_TEMPLATE
from .tools import (
END_EXECUTION_TOOL,
Expand All @@ -19,4 +23,6 @@
"RAISE_ERROR_TOOL",
"EndExecutionToolSchemaModel",
"RaiseErrorToolSchemaModel",
"PromptUserSettings",
"get_chat_system_prompt",
]
Loading