diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index d81fa8153..e15e0cc8c 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -93,6 +93,7 @@ UiPathSessionStartEvent, ) from .tool import ( + UiPathConversationExecutingToolCallEvent, UiPathConversationToolCall, UiPathConversationToolCallConfirmation, UiPathConversationToolCallConfirmationData, @@ -157,6 +158,7 @@ "UiPathConversationCitationData", "UiPathConversationCitation", # Tool + "UiPathConversationExecutingToolCallEvent", "UiPathConversationToolCallStartEvent", "UiPathConversationToolCallEndEvent", "UiPathConversationToolCallConfirmation", diff --git a/packages/uipath-core/src/uipath/core/chat/tool.py b/packages/uipath-core/src/uipath/core/chat/tool.py index 8af5fb604..ab36b2d30 100644 --- a/packages/uipath-core/src/uipath/core/chat/tool.py +++ b/packages/uipath-core/src/uipath/core/chat/tool.py @@ -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) @@ -43,6 +45,18 @@ 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.""" @@ -82,6 +96,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") diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 2a382a59e..9250e6277 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -13,8 +13,11 @@ UiPathConversationEvent, UiPathConversationExchangeEndEvent, UiPathConversationExchangeEvent, + UiPathConversationExecutingToolCallEvent, UiPathConversationMessageEvent, UiPathConversationToolCallConfirmationEvent, + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, ) from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.chat import UiPathChatProtocol @@ -124,7 +127,9 @@ def __init__( self._tool_confirmation_event = asyncio.Event() self._tool_confirmation_value: ( - UiPathConversationToolCallConfirmationEvent | None + UiPathConversationToolCallConfirmationEvent + | UiPathConversationToolCallEndEvent + | None ) = None self._current_message_id: str | None = None @@ -378,6 +383,48 @@ async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): """ return None + async def emit_executing_tool_call_event( + self, resume_trigger: UiPathResumeTrigger + ) -> None: + """Emit an executingToolCall event for client-side tool execution. + + Only emits for triggers marked with is_execution_phase=True. + This fires exactly once per client-side tool call — for Path 3 (no confirm) + and for Path 4 (after confirmation, on the execution interrupt). + Confirmation-only interrupts (Paths 2/4 first interrupt) are skipped. + """ + + 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: + logger.info( + f"emit_executing_tool_call_event: missing tool_call_id or tool_name, skipping. tool_call_id={tool_call_id}, tool_name={tool_name}" + ) + 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() @@ -424,13 +471,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_confirmation_value = confirm + self._tool_confirmation_event.set() + elif end := tool_call.end: + self._tool_confirmation_value = end + self._tool_confirmation_event.set() except Exception as e: logger.warning(f"Error parsing conversation event: {e}") diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 7694a861e..809912b9f 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -115,6 +115,7 @@ class AgentToolType(str, CaseInsensitiveEnum): INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" + CLIENT_SIDE = "ClientSide" UNKNOWN = "Unknown" # fallback branch discriminator @@ -889,6 +890,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').""" @@ -902,6 +912,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): AgentIntegrationToolResourceConfig, AgentInternalToolResourceConfig, AgentIxpExtractionResourceConfig, + AgentClientSideToolResourceConfig, AgentUnknownToolResourceConfig, # when parent sets type="Unknown" ], Field(discriminator="type"), @@ -1276,6 +1287,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "integration": "Integration", "internal": "Internal", "ixp": "Ixp", + "clientside": "ClientSide", "unknown": "Unknown", } CONTEXT_MODE_MAP = {