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: 4 additions & 0 deletions packages/uipath-core/src/uipath/core/chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
)
from .event import UiPathConversationEvent, UiPathConversationLabelUpdatedEvent
from .exchange import (
UiPathClientSideToolDeclaration,
UiPathConversationExchange,
UiPathConversationExchangeData,
UiPathConversationExchangeEndEvent,
Expand Down Expand Up @@ -107,6 +108,7 @@
UiPathSessionStartEvent,
)
from .tool import (
UiPathConversationExecutingToolCallEvent,
Comment thread
norman-le marked this conversation as resolved.
UiPathConversationToolCall,
UiPathConversationToolCallConfirmation,
UiPathConversationToolCallConfirmationData,
Expand Down Expand Up @@ -138,6 +140,7 @@
"UiPathSessionEndingEvent",
"UiPathSessionEndEvent",
# Exchange
"UiPathClientSideToolDeclaration",
"UiPathConversationExchangeStartEvent",
"UiPathConversationExchangeEndEvent",
"UiPathConversationExchangeEvent",
Expand Down Expand Up @@ -171,6 +174,7 @@
"UiPathConversationCitationData",
"UiPathConversationCitation",
# Tool
"UiPathConversationExecutingToolCallEvent",
"UiPathConversationToolCallStartEvent",
"UiPathConversationToolCallEndEvent",
"UiPathConversationToolCallConfirmation",
Expand Down
13 changes: 13 additions & 0 deletions packages/uipath-core/src/uipath/core/chat/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,24 @@
)


class UiPathClientSideToolDeclaration(BaseModel):
"""A client-side tool declaration from the SDK client."""

name: str
input_schema: dict[str, Any] | None = Field(None, alias="inputSchema")
output_schema: dict[str, Any] | None = Field(None, alias="outputSchema")

model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)


class UiPathConversationExchangeStartEvent(BaseModel):
"""Signals the start of an exchange of messages within a conversation."""

conversation_sequence: int | None = Field(None, alias="conversationSequence")
metadata: dict[str, Any] | None = Field(None, alias="metaData")
client_side_tools: list[UiPathClientSideToolDeclaration] | None = Field(
None, alias="clientSideTools"
)
timestamp: str | None = None

model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
Expand Down
19 changes: 19 additions & 0 deletions packages/uipath-core/src/uipath/core/chat/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class UiPathConversationToolCallStartEvent(BaseModel):
metadata: dict[str, Any] | None = Field(None, alias="metaData")
require_confirmation: bool | None = Field(None, alias="requireConfirmation")
input_schema: Any | None = Field(None, alias="inputSchema")
is_client_side_tool: bool | None = Field(None, alias="isClientSideTool")
output_schema: Any | None = Field(None, alias="outputSchema")

model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)

Expand All @@ -43,6 +45,20 @@ class UiPathConversationToolCallEndEvent(BaseModel):
model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)


class UiPathConversationExecutingToolCallEvent(BaseModel):
"""Signals the client that the tool is about to be executed.

Emitted in all paths. For client-side tools, the client should begin
executing its handler upon receiving this event.
"""

tool_name: str = Field(..., alias="toolName")
timestamp: str | None = None
input: dict[str, Any] | None = None

model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)


class UiPathConversationToolCallConfirmationEvent(BaseModel):
"""Signals a tool call confirmation (approve/reject) from the client."""

Expand Down Expand Up @@ -82,6 +98,9 @@ class UiPathConversationToolCallEvent(BaseModel):
confirm: UiPathConversationToolCallConfirmationEvent | None = Field(
None, alias="confirmToolCall"
)
executing: UiPathConversationExecutingToolCallEvent | None = Field(
None, alias="executingToolCall"
)
meta_event: dict[str, Any] | None = Field(None, alias="metaEvent")
error: UiPathConversationErrorEvent | None = Field(None, alias="toolCallError")

Expand Down
91 changes: 58 additions & 33 deletions packages/uipath/src/uipath/_cli/_chat/_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
UiPathConversationEvent,
UiPathConversationExchangeEndEvent,
UiPathConversationExchangeEvent,
UiPathConversationExecutingToolCallEvent,
UiPathConversationMessageEvent,
UiPathConversationToolCallConfirmationEvent,
UiPathConversationToolCallEndEvent,
UiPathConversationToolCallEvent,
)
from uipath.core.triggers import UiPathResumeTrigger
from uipath.runtime.chat import UiPathChatProtocol
Expand Down Expand Up @@ -122,9 +125,11 @@ def __init__(
self._client: Any | None = None
self._connected_event = asyncio.Event()

self._tool_confirmation_event = asyncio.Event()
self._tool_confirmation_value: (
UiPathConversationToolCallConfirmationEvent | None
self._tool_resume_event = asyncio.Event()
self._tool_resume_value: (
UiPathConversationToolCallConfirmationEvent
| UiPathConversationToolCallEndEvent
| None
) = None
self._current_message_id: str | None = None

Expand Down Expand Up @@ -360,35 +365,55 @@ 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.

Tool confirmation — the only interrupt pattern CAS uses today — is
handled end-to-end via ``startToolCall`` with ``requireConfirmation:
true`` paired with ``wait_for_resume()``. This is deliberately
simpler than the old interrupt-based flow: CAS needs
``requireConfirmation`` on the tool call event itself to render the
confirmation UI, so a parallel ``startInterrupt`` event would be
redundant.

The only hypothetical reason to put work here is a generic,
non-tool-call agent interrupt (e.g. a coded agent calling
``interrupt("do you want to continue?")``). Nothing uses that today
and it's not a near-term requirement — the method is kept for
generic flexibility.
"""Emit an executingToolCall event if the trigger is marked with is_execution_phase.

Called by the runtime loop for every durable interrupt. Emits executingToolCall
for triggers that signal execution is about to begin. The
is_execution_phase marker ensures it fires exactly once per tool call.
"""
return None
request = (
resume_trigger.api_resume.request if resume_trigger.api_resume else None
)
if not request or not isinstance(request, dict):
return

if not request.get("is_execution_phase"):
return

tool_call_id = request.get("tool_call_id")
tool_name = request.get("tool_name")
tool_input = request.get("input")

if not tool_call_id or not tool_name:
return

if not self._current_message_id:
return

executing_event = UiPathConversationMessageEvent(
message_id=self._current_message_id,
tool_call=UiPathConversationToolCallEvent(
tool_call_id=tool_call_id,
executing=UiPathConversationExecutingToolCallEvent(
tool_name=tool_name,
input=tool_input,
),
),
)
await self.emit_message_event(executing_event)

async def wait_for_resume(self) -> dict[str, Any]:
"""Wait for a confirmToolCall event to be received."""
self._tool_confirmation_event.clear()
self._tool_confirmation_value = None
"""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()

await self._tool_confirmation_event.wait()
value = self._tool_resume_value
self._tool_resume_value = None
self._tool_resume_event.clear()

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

@property
Expand Down Expand Up @@ -424,13 +449,13 @@ async def _handle_conversation_event(
parsed_event.exchange
and parsed_event.exchange.message
and (tool_call := parsed_event.exchange.message.tool_call)
and (confirm := tool_call.confirm)
):
logger.info(
f"Received confirmToolCall for tool_call_id: {tool_call.tool_call_id}, approved: {confirm.approved}"
)
self._tool_confirmation_value = confirm
self._tool_confirmation_event.set()
if confirm := tool_call.confirm:
self._tool_resume_value = confirm
self._tool_resume_event.set()
elif end := tool_call.end:
self._tool_resume_value = end
self._tool_resume_event.set()
except Exception as e:
logger.warning(f"Error parsing conversation event: {e}")

Expand Down
12 changes: 12 additions & 0 deletions packages/uipath/src/uipath/agent/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class AgentToolType(str, CaseInsensitiveEnum):
INTEGRATION = "Integration"
INTERNAL = "Internal"
IXP = "Ixp"
CLIENT_SIDE = "ClientSide"
UNKNOWN = "Unknown" # fallback branch discriminator


Expand Down Expand Up @@ -895,6 +896,15 @@ class AgentInternalToolResourceConfig(BaseAgentToolResourceConfig):
)


class AgentClientSideToolResourceConfig(BaseAgentToolResourceConfig):
"""Resource config for client-side tools executed by the client SDK."""

type: Literal[AgentToolType.CLIENT_SIDE] = AgentToolType.CLIENT_SIDE
properties: BaseResourceProperties = Field(default_factory=BaseResourceProperties)
output_schema: Optional[Dict[str, Any]] = Field(None, alias="outputSchema")
arguments: Optional[Dict[str, Any]] = Field(default_factory=dict)


class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig):
"""Fallback for unknown tool types (parent normalizer sets type='Unknown')."""

Expand All @@ -908,6 +918,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig):
AgentIntegrationToolResourceConfig,
AgentInternalToolResourceConfig,
AgentIxpExtractionResourceConfig,
AgentClientSideToolResourceConfig,
AgentUnknownToolResourceConfig, # when parent sets type="Unknown"
],
Field(discriminator="type"),
Expand Down Expand Up @@ -1290,6 +1301,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None:
"integration": "Integration",
"internal": "Internal",
"ixp": "Ixp",
"clientside": "ClientSide",
"unknown": "Unknown",
}
CONTEXT_MODE_MAP = {
Expand Down
Loading
Loading