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
2 changes: 1 addition & 1 deletion packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath"
version = "2.11.12"
version = "2.11.13"
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"
Expand Down
141 changes: 113 additions & 28 deletions packages/uipath/src/uipath/_cli/_chat/_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import logging
import os
from collections import deque
from typing import Any
from urllib.parse import urlparse

Expand All @@ -25,6 +26,14 @@

logger = logging.getLogger(__name__)

# Type for tool call resume values (confirmToolCall or endToolCall payloads)
ToolResumeValue = (
UiPathConversationToolCallConfirmationEvent | UiPathConversationToolCallEndEvent
)

# Wrapper that pairs a resume value with its tool_call_id for keyed matching
ToolResumeItem = dict[str, Any] # {"tool_call_id": str, "value": ToolResumeValue}


class CASErrorId:
"""Error IDs for the Conversational Agent Service (CAS), matching the Temporal backend."""
Expand Down Expand Up @@ -129,12 +138,29 @@ def __init__(
self._client: Any | None = None
self._connected_event = asyncio.Event()

self._tool_resume_event = asyncio.Event()
self._tool_resume_value: (
UiPathConversationToolCallConfirmationEvent
| UiPathConversationToolCallEndEvent
| None
) = None
# --- Tool call resume state ---
# When the LLM invokes multiple tools in one turn, the client can send
# back confirmToolCall / endToolCall responses concurrently and in any
# order. Three data structures coordinate matching each response to the
# correct wait_for_resume() call:
#
# 1. _expected_tool_call_ids (deque):
# Ordered queue of tool_call_ids populated by emit_interrupt_event()
# (called by the runtime BEFORE each wait_for_resume()). Tells
# wait_for_resume() WHICH tool_call_id it should consume next.
#
# 2. _tool_resume_results (dict):
# Responses that arrived BEFORE wait_for_resume() was called for that
# tool_call_id. When wait_for_resume() runs, it checks here first
# and returns immediately if a match exists — no blocking needed.
#
# 3. _tool_resume_pending (dict of Futures):
# Created by wait_for_resume() when the response hasn't arrived yet.
# When the response later arrives in _handle_conversation_event, the
# Future is resolved and wait_for_resume() unblocks.
self._tool_resume_results: dict[str, ToolResumeItem] = {}
self._tool_resume_pending: dict[str, asyncio.Future[ToolResumeItem]] = {}
self._expected_tool_call_ids: deque[str] = deque()
self._current_message_id: str | None = None

# Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from
Expand Down Expand Up @@ -376,14 +402,19 @@ async def emit_exchange_error_event(self, error: Exception) -> None:
raise RuntimeError(f"Failed to send exchange error event: {e}") from e

async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger):
"""No-op.
"""Register the trigger's tool_call_id for the upcoming wait_for_resume().

Tool confirmation is handled end-to-end via ``startToolCall`` with
``requireConfirmation: true`` paired with ``wait_for_resume()``.
executingToolCall is emitted by the MessageMapper (non-confirmed
tools) and the runtime loop post-confirmation (confirmed tools).
Does not emit any websocket event — tool confirmation and execution
events are handled elsewhere. The runtime calls this immediately
before wait_for_resume() for each trigger, so we record the
tool_call_id here so wait_for_resume() knows which response to match.
"""
return None
if resume_trigger.api_resume and isinstance(
resume_trigger.api_resume.request, dict
):
tool_call_id = resume_trigger.api_resume.request.get("tool_call_id")
if isinstance(tool_call_id, str) and tool_call_id:
self._expected_tool_call_ids.append(tool_call_id)
Comment thread
Copilot marked this conversation as resolved.

async def emit_executing_tool_call_event(
self,
Expand All @@ -410,21 +441,77 @@ async def emit_executing_tool_call_event(
await self.emit_message_event(executing_event)

async def wait_for_resume(self) -> dict[str, Any]:
"""Wait for a tool resume event (confirmToolCall or endToolCall) to be received."""
if self._tool_resume_value is None:
self._tool_resume_event.clear()
await self._tool_resume_event.wait()
"""Wait for a tool resume event (confirmToolCall or endToolCall).

Pops the next expected tool_call_id (registered by emit_interrupt_event)
and returns the matching response. Two cases:

1. Response already arrived (stored in _tool_resume_results) — return
immediately without blocking.
2. Response hasn't arrived yet — create a Future in _tool_resume_pending,
block until _handle_conversation_event resolves it.

Returns:
The resume data dict, including ``tool_call_id``.
"""
Comment on lines +444 to +456

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice! Thanks for the detailed flow + demoes.

Two questions:

  • In the second demo, if you didn't submit the tool results in order (e.g. instead of 1,2,3,4,5 you did 3,5,1,4,2) would it work?
  • More of a design question, let me know if I'm misunderstanding anything: Any reason we need to dequeue just the next expected tool_call_id? If we just had a data-structure list with all N tool-calls, could we simplify by whenever a tool-resume event occurs, checking all the tool-call-ids in that list to see matches?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good questions.

  1. Yes, it works out of order. Responses get stored in _tool_resume_results by tool_call_id regardless of arrival order. When wait_for_resume() runs, it looks up by the expected ID, not by arrival order. The test test_concurrent_tool_calls_out_of_order tests this.

  2. The deque exists because wait_for_resume() has no parameter — it doesn't know which tool_call_id to return. The runtime calls it N times in a sequential loop:
    https://github.com/UiPath/uipath-runtime-python/blob/2b45546dbeb249541869a4fc227cac0839d51ca5/src/uipath/runtime/chat/runtime.py#L106

for trigger in api_triggers:
    emit_interrupt_event(trigger)     # "I'm about to wait for toolcall-3"
    resume_data = wait_for_resume()   # "give me toolcall-3's result"
    resume_map[trigger.interrupt_id] = resume_data

Each call must return the result for that specific trigger. Without the deque, wait_for_resume() could return any available result and pair it with the wrong trigger (e.g. assign toolcall-1's result to toolcall-3, etc.)

One option was to pass tool_call_id as a parameter to wait_for_resume(tool_call_id) — then you'd only need the results/pending dicts, no deque. But that requires changing the UiPathChatProtocol, which I wanted to avoid. The deque bridges that gap without a protocol change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense, thanks!

if not self._expected_tool_call_ids:
raise RuntimeError(
"wait_for_resume() called but no tool_call_id was registered "
"by emit_interrupt_event(). This indicates a caller/protocol mismatch."
)

expected_id = self._expected_tool_call_ids.popleft()

value = self._tool_resume_value
self._tool_resume_value = None
self._tool_resume_event.clear()
if expected_id in self._tool_resume_results:
# Response arrived before we got here — return it immediately
item = self._tool_resume_results.pop(expected_id)
else:
# Response hasn't arrived yet — wait for it
future: asyncio.Future[ToolResumeItem] = (
asyncio.get_running_loop().create_future()
)
self._tool_resume_pending[expected_id] = future
item = await future
Comment thread
norman-le marked this conversation as resolved.

Comment thread
norman-le marked this conversation as resolved.
value = item["value"]
result = value.model_dump(mode="python", by_alias=False)
result["tool_call_id"] = item["tool_call_id"]
return result

def _resolve_or_store_resume(
self, tool_call_id: str, value: ToolResumeValue
) -> None:
"""Route an incoming confirmToolCall/endToolCall to the correct consumer.

"""For the case where there's no tool confirmation and the client side tool sends endToolCall back before wait_for_resume is called.
Unlikely in practice, but possible in theory, since executingToolCall is emitted during the streaming.
Called from _handle_conversation_event when a tool resume response
arrives from the client. Two cases:

1. wait_for_resume() is already waiting (Future in _tool_resume_pending)
— resolve the Future so it unblocks immediately.
2. wait_for_resume() hasn't been called yet for this tool_call_id
— store in _tool_resume_results so it's found instantly when
wait_for_resume() runs later.
"""
if value:
return value.model_dump(mode="python", by_alias=False)
return {}
item: ToolResumeItem = {"tool_call_id": tool_call_id, "value": value}
if tool_call_id in self._tool_resume_pending:
future = self._tool_resume_pending.pop(tool_call_id)
if not future.done():
future.set_result(item)
else:
# Future was cancelled or already resolved — store the payload
# so a subsequent wait_for_resume() can still find it.
logger.warning(
f"Resume for tool_call_id={tool_call_id} — "
"future already done, storing as fallback."
)
self._tool_resume_results[tool_call_id] = item
else:
if tool_call_id in self._tool_resume_results:
logger.warning(
f"Duplicate resume for tool_call_id={tool_call_id} — "
"overwriting previously stored result."
)
self._tool_resume_results[tool_call_id] = item

@property
def is_connected(self) -> bool:
Expand Down Expand Up @@ -461,11 +548,9 @@ async def _handle_conversation_event(
and (tool_call := parsed_event.exchange.message.tool_call)
):
if confirm := tool_call.confirm:
self._tool_resume_value = confirm
self._tool_resume_event.set()
self._resolve_or_store_resume(tool_call.tool_call_id, confirm)
elif end := tool_call.end:
self._tool_resume_value = end
self._tool_resume_event.set()
self._resolve_or_store_resume(tool_call.tool_call_id, end)
except Exception as e:
logger.warning(f"Error parsing conversation event: {e}")

Expand Down
Loading
Loading