Skip to content
Merged
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,6 +1,6 @@
[project]
name = "uipath-dev"
version = "0.0.78"
version = "0.0.79"
description = "UiPath Developer Console"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand All @@ -10,7 +10,7 @@ dependencies = [
"pyperclip>=1.11.0, <2.0.0",
"fastapi>=0.128.8",
"uvicorn[standard]>=0.40.0",
"uipath>=2.10.0, <2.11.0",
"uipath>=2.10.57, <2.11.0",
"aiosqlite>=0.20.0",
"pywinpty>=2.0.0; sys_platform == 'win32'",
"mcp[cli]>=1.0.0",
Expand Down
2 changes: 0 additions & 2 deletions src/uipath/dev/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ def add(
role=self.get_role(event),
content_parts=[],
tool_calls=[],
interrupts=[],
created_at=self.get_timestamp(event),
updated_at=self.get_timestamp(event),
)
Expand Down Expand Up @@ -205,7 +204,6 @@ def get_user_message(user_text: str) -> UiPathConversationMessage:
)
],
tool_calls=[],
interrupts=[],
role="user",
)

Expand Down
6 changes: 0 additions & 6 deletions src/uipath/dev/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@

from uipath.dev.models.data import (
ChatData,
InterruptData,
LogData,
StateData,
TraceData,
Expand Down Expand Up @@ -85,7 +84,6 @@ def __init__(
on_trace=self._on_trace,
on_chat=self._on_chat,
on_state=self._on_state,
on_interrupt=self._on_interrupt,
debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode),
on_run_removed=self.connection_manager.remove_run_subscriptions,
)
Expand Down Expand Up @@ -303,10 +301,6 @@ def _on_chat(self, chat_data: ChatData) -> None:
"""Broadcast chat message to subscribed WebSocket clients."""
self.connection_manager.broadcast_chat(chat_data)

def _on_interrupt(self, interrupt_data: InterruptData) -> None:
"""Broadcast chat interrupt to subscribed WebSocket clients."""
self.connection_manager.broadcast_interrupt(interrupt_data)

def _on_state(self, state_data: StateData) -> None:
"""Broadcast state transition to subscribed WebSocket clients."""
self.connection_manager.broadcast_state(state_data)
Expand Down
22 changes: 12 additions & 10 deletions src/uipath/dev/server/ws/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,10 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
if run_id and text:
await _handle_chat_message(server, run_id, text)

elif command == ClientCommand.CHAT_INTERRUPT_RESPONSE.value:
elif command == ClientCommand.CHAT_CONFIRM_TOOL_CALL.value:
run_id = payload.get("run_id", "")
data = payload.get("data", {})
if run_id:
_handle_interrupt_response(server, run_id, data)
_handle_confirm_tool_call(server, run_id, payload)

elif command == ClientCommand.DEBUG_STEP.value:
run_id = payload.get("run_id", "")
Expand Down Expand Up @@ -136,15 +135,18 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
manager.disconnect(websocket)


def _handle_interrupt_response(server: Any, run_id: str, data: Any) -> None:
"""Process an interrupt response from a WebSocket client."""
def _handle_confirm_tool_call(
server: Any, run_id: str, payload: dict[str, Any]
) -> None:
"""Process a tool call confirmation (approve/reject) from a WebSocket client."""
run = server.run_service.get_run(run_id)
if run is None or run.status != "suspended":
if run is None:
return

chat_bridge = server.run_service.get_chat_bridge(run_id)
if chat_bridge:
server.run_service.resume_chat(run, data if isinstance(data, dict) else {})
server.run_service.confirm_tool_call(
run_id,
approved=bool(payload.get("approved", False)),
input=payload.get("input"),
)


async def _handle_chat_message(server: Any, run_id: str, text: str) -> None:
Expand Down
9 changes: 0 additions & 9 deletions src/uipath/dev/server/ws/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from uipath.dev.models.data import (
ChatData,
InterruptData,
LogData,
StateData,
TraceData,
Expand All @@ -19,7 +18,6 @@
from uipath.dev.models.execution import ExecutionRun
from uipath.dev.server.serializers import (
serialize_chat,
serialize_interrupt,
serialize_log,
serialize_run,
serialize_state,
Expand Down Expand Up @@ -154,13 +152,6 @@ def broadcast_chat(self, chat_data: ChatData) -> None:
msg = server_message(ServerEvent.CHAT, serialize_chat(chat_data))
self._schedule_broadcast(chat_data.run_id, msg)

def broadcast_interrupt(self, interrupt_data: InterruptData) -> None:
"""Broadcast a chat interrupt to run subscribers."""
msg = server_message(
ServerEvent.CHAT_INTERRUPT, serialize_interrupt(interrupt_data)
)
self._schedule_broadcast(interrupt_data.run_id, msg)

def broadcast_state(self, state_data: StateData) -> None:
"""Broadcast a state transition to all connected clients.

Expand Down
3 changes: 1 addition & 2 deletions src/uipath/dev/server/ws/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ class ServerEvent(str, Enum):
LOG = "log"
TRACE = "trace"
CHAT = "chat"
CHAT_INTERRUPT = "chat.interrupt"
STATE = "state"
RELOAD = "reload"
FILES_CHANGED = "files.changed"
Expand All @@ -31,7 +30,7 @@ class ClientCommand(str, Enum):
SUBSCRIBE = "subscribe"
UNSUBSCRIBE = "unsubscribe"
CHAT_MESSAGE = "chat.message"
CHAT_INTERRUPT_RESPONSE = "chat.interrupt_response"
CHAT_CONFIRM_TOOL_CALL = "chat.confirm_tool_call"
DEBUG_STEP = "debug.step"
DEBUG_CONTINUE = "debug.continue"
DEBUG_STOP = "debug.stop"
Expand Down
64 changes: 42 additions & 22 deletions src/uipath/dev/services/chat_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import logging
from typing import Any, Callable

from uipath.core.chat import UiPathConversationMessageEvent
from uipath.core.chat import (
UiPathConversationMessageEvent,
UiPathConversationToolCallConfirmationEvent,
)
from uipath.runtime.resumable.trigger import UiPathResumeTrigger

logger = logging.getLogger(__name__)
Expand All @@ -13,28 +16,32 @@
class WebChatBridge:
"""Bridge between the web server and UiPathChatRuntime.

Implements UiPathChatProtocol. Broadcasts message and interrupt events
to WebSocket clients via callbacks, and blocks on wait_for_resume until
the user responds.
Implements UiPathChatProtocol. Forwards message events to WebSocket clients
and resumes runtime execution when the user confirms a tool call.

Tool confirmation flows through ``startToolCall`` events with
``requireConfirmation: true`` and ``inputSchema``; the user's decision is
delivered back as a ``UiPathConversationToolCallConfirmationEvent`` via
``set_tool_confirmation`` and consumed by ``wait_for_resume``.
"""

def __init__(self) -> None:
"""Initialize the web chat bridge."""
self._resume_event = asyncio.Event()
self._resume_data: dict[str, Any] = {}
self._tool_confirmation_event = asyncio.Event()
self._tool_confirmation_value: (
UiPathConversationToolCallConfirmationEvent | None
) = None

# Callbacks (wired by RunService / server)
self.on_message: Callable[[UiPathConversationMessageEvent], None] | None = None
self.on_interrupt: Callable[[UiPathResumeTrigger], None] | None = None
self.on_exchange_end: Callable[[], None] | None = None

async def connect(self) -> None:
"""Establish connection to chat service."""
logger.debug("WebChatBridge connected")

async def disconnect(self) -> None:
"""Close connection and send exchange end event."""
self._resume_event.set()
"""Unblock any waiting coroutine and close."""
self._tool_confirmation_event.set()
logger.debug("WebChatBridge disconnected")

async def emit_message_event(
Expand All @@ -45,9 +52,13 @@ async def emit_message_event(
self.on_message(message_event)

async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger) -> None:
"""Forward an interrupt event via callback."""
if self.on_interrupt:
self.on_interrupt(resume_trigger)
"""No-op.

Tool confirmations now flow on the tool call event itself
(``requireConfirmation`` + ``confirmToolCall``); generic agent
interrupts have no near-term consumer in the dev console.
"""
return None

async def emit_exchange_end_event(self) -> None:
"""Send an exchange end event."""
Expand All @@ -59,12 +70,21 @@ async def emit_exchange_error_event(self, error: Exception) -> None:
logger.error(f"Exchange error: {error}")

async def wait_for_resume(self) -> dict[str, Any]:
"""Wait for the user to respond to an interrupt."""
self._resume_event.clear()
await self._resume_event.wait()
return self._resume_data

def resume(self, data: dict[str, Any]) -> None:
"""Store resume data and signal the waiting coroutine."""
self._resume_data = data
self._resume_event.set()
"""Wait for a confirmToolCall event to be received."""
self._tool_confirmation_event.clear()
self._tool_confirmation_value = None

await self._tool_confirmation_event.wait()

if self._tool_confirmation_value is not None:
return self._tool_confirmation_value.model_dump(
mode="python", by_alias=False
)
return {}

def set_tool_confirmation(
self, confirmation: UiPathConversationToolCallConfirmationEvent
) -> None:
"""Deliver the user's tool call confirmation and unblock the runtime."""
self._tool_confirmation_value = confirmation
self._tool_confirmation_event.set()
81 changes: 15 additions & 66 deletions src/uipath/dev/services/run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pydantic import BaseModel
from uipath.core.chat import (
UiPathConversationMessageEvent,
UiPathConversationToolCallConfirmationValue,
UiPathConversationToolCallConfirmationEvent,
)
from uipath.core.tracing import UiPathTraceManager
from uipath.runtime import (
Expand All @@ -32,12 +32,10 @@
UiPathErrorContract,
)
from uipath.runtime.events import UiPathRuntimeStateEvent
from uipath.runtime.resumable.trigger import UiPathResumeTrigger

from uipath.dev.infrastructure import RunContextExporter, RunContextLogHandler
from uipath.dev.models.data import (
ChatData,
InterruptData,
LogData,
StateData,
TraceData,
Expand All @@ -52,7 +50,6 @@
TraceCallback = Callable[[TraceData], None]
ChatCallback = Callable[[ChatData], None]
StateCallback = Callable[[StateData], None]
InterruptCallback = Callable[[InterruptData], None]


class DebugBridgeProtocol(UiPathDebugProtocol, Protocol):
Expand Down Expand Up @@ -98,7 +95,6 @@ def __init__(
on_trace: TraceCallback | None = None,
on_chat: ChatCallback | None = None,
on_state: StateCallback | None = None,
on_interrupt: InterruptCallback | None = None,
debug_bridge_factory: DebugBridgeFactory | None = None,
on_run_removed: Callable[[str], None] | None = None,
) -> None:
Expand All @@ -112,7 +108,6 @@ def __init__(
self.on_trace = on_trace
self.on_chat = on_chat
self.on_state = on_state
self.on_interrupt = on_interrupt
self._debug_bridge_factory = debug_bridge_factory
self._on_run_removed = on_run_removed

Expand Down Expand Up @@ -208,9 +203,6 @@ async def execute(self, run: ExecutionRun) -> None:
chat_bridge.on_message = lambda evt: self._handle_chat_message_event(
run, evt
)
chat_bridge.on_interrupt = lambda trigger: self._handle_interrupt(
run, trigger
)
self.chat_bridges[run.id] = chat_bridge

# ChatRuntime handles suspend/resume internally
Expand Down Expand Up @@ -442,13 +434,20 @@ def get_chat_bridge(self, run_id: str) -> WebChatBridge | None:
"""Get the chat bridge for a run."""
return self.chat_bridges.get(run_id)

def resume_chat(self, run: ExecutionRun, data: dict[str, Any]) -> None:
"""Resume a suspended chat run with interrupt response data."""
chat_bridge = self.chat_bridges.get(run.id)
if chat_bridge:
run.status = "running"
self._emit_run_updated(run)
chat_bridge.resume(data)
def confirm_tool_call(
self, run_id: str, approved: bool, input: Any | None = None
) -> None:
"""Deliver a tool call confirmation to a chat-mode run.

Forwarded as ``UiPathConversationToolCallConfirmationEvent`` to the
``WebChatBridge``, which unblocks the runtime's ``wait_for_resume``.
"""
chat_bridge = self.chat_bridges.get(run_id)
if chat_bridge is None:
return
chat_bridge.set_tool_confirmation(
UiPathConversationToolCallConfirmationEvent(approved=approved, input=input)
)

def get_debug_bridge(self, run_id: str) -> DebugBridgeProtocol | None:
"""Get the debug bridge for a run."""
Expand All @@ -466,56 +465,6 @@ def _handle_chat_message_event(
)
self.on_chat(chat_data)

def _handle_interrupt(
self, run: ExecutionRun, trigger: UiPathResumeTrigger
) -> None:
"""Handle an interrupt event from the chat bridge."""
payload = trigger.payload
interrupt_id = trigger.interrupt_id or ""
interrupt_data: InterruptData

# Try strongly-typed tool call confirmation first
tc = self._try_parse_tool_call_confirmation(payload)
if tc is not None:
interrupt_data = InterruptData(
run_id=run.id,
interrupt_id=interrupt_id,
interrupt_type="tool_call_confirmation",
tool_call_id=tc.tool_call_id,
tool_name=tc.tool_name,
input_schema=tc.input_schema,
input_value=tc.input_value,
)
else:
interrupt_data = InterruptData(
run_id=run.id,
interrupt_id=interrupt_id,
interrupt_type="generic",
content=payload,
)

run.status = "suspended"
self._emit_run_updated(run)

if self.on_interrupt is not None:
self.on_interrupt(interrupt_data)

@staticmethod
def _try_parse_tool_call_confirmation(
payload: Any,
) -> UiPathConversationToolCallConfirmationValue | None:
"""Try to parse payload as a tool call confirmation value."""
if isinstance(payload, UiPathConversationToolCallConfirmationValue):
return payload
if isinstance(payload, dict):
try:
return UiPathConversationToolCallConfirmationValue.model_validate(
payload
)
except Exception:
return None
return None

def _handle_state_update(self, run_id: str, state: UiPathRuntimeStateEvent) -> None:
"""Handle state update from debug runtime."""
run = self.runs.get(run_id)
Expand Down
Loading
Loading