diff --git a/demo/mock_support_runtime.py b/demo/mock_support_runtime.py index 138657e..b9b166d 100644 --- a/demo/mock_support_runtime.py +++ b/demo/mock_support_runtime.py @@ -15,9 +15,6 @@ UiPathConversationContentPartEvent, UiPathConversationContentPartStartEvent, ) -from uipath.core.chat.interrupt import ( - UiPathConversationToolCallConfirmationValue, -) from uipath.core.chat.message import ( UiPathConversationMessageEndEvent, UiPathConversationMessageStartEvent, @@ -197,6 +194,8 @@ def __init__(self, entrypoint: str = ENTRYPOINT_SUPPORT_CHAT) -> None: self.tracer = trace.get_tracer("uipath.dev.mock.support-chat") self._suspended_turn: dict[str, Any] | None = None self._suspended_message_id: str | None = None + self._suspended_interrupt_id: str | None = None + self._suspended_call_id: str | None = None async def get_schema(self) -> UiPathRuntimeSchema: """Get the schema for the support chat runtime.""" @@ -505,8 +504,49 @@ async def stream( # --- Resume after tool approval --- if is_resuming and self._suspended_turn is not None: turn = self._suspended_turn + message_id = self._suspended_message_id or "" + interrupt_id = self._suspended_interrupt_id or "" + call_id = self._suspended_call_id or "" self._suspended_turn = None self._suspended_message_id = None + self._suspended_interrupt_id = None + self._suspended_call_id = None + + # ``current_input`` on resume is ``{interrupt_id: {approved, input}}`` + # — extract the user's decision. + confirmation = ( + input.get(interrupt_id, {}) if isinstance(input, dict) else {} + ) + approved = bool(confirmation.get("approved", False)) + approval_tool = turn["approval_tool"] + + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=call_id, + end=UiPathConversationToolCallEndEvent( + timestamp=datetime.now().isoformat(), + output=approval_tool["output"] + if approved + else "Cancelled by user.", + cancelled=not approved, + ), + ), + ), + ) + + if not approved: + # User rejected — skip phase 2 and yield a SUCCESSFUL result so + # UiPathChatRuntime exits its outer loop. Returning bare here + # would leave ``execution_completed = False`` and trigger + # another ``delegate.stream`` iteration, advancing to the next + # turn. + yield UiPathRuntimeResult( + output={"reply": "Cancelled by user."}, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + return resume_span = self.tracer.start_span( "support_chat.resume", @@ -639,35 +679,50 @@ async def stream( await asyncio.sleep(0.5) yield self._node_state("tools", C) - # --- Check if this turn needs tool approval --- + # --- Approval flow: emit startToolCall(requireConfirmation=true) + # so the UI renders the inline approve/reject panel, then suspend + # so UiPathChatRuntime awaits ``bridge.wait_for_resume()``. The + # bridge unblocks when the user clicks approve/reject and the + # confirmation flows through ``run_service.confirm_tool_call``. if needs_approval: approval_tool = turn["approval_tool"] + approval_call_id = f"call_{uuid4().hex[:12]}" interrupt_id = str(uuid4()) - tool_call_id = f"call_{uuid4().hex[:12]}" - # Store state for resume + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=approval_call_id, + start=UiPathConversationToolCallStartEvent( + tool_name=approval_tool["name"], + timestamp=datetime.now().isoformat(), + input=approval_tool["input"], + require_confirmation=True, + input_schema=approval_tool["input_schema"], + ), + ), + ), + ) + self._suspended_turn = turn self._suspended_message_id = message_id + self._suspended_interrupt_id = interrupt_id + self._suspended_call_id = approval_call_id yield UiPathRuntimeResult( status=UiPathRuntimeStatus.SUSPENDED, - output={"paused_for_approval": approval_tool["name"]}, + output={"awaiting_confirmation": approval_tool["name"]}, triggers=[ UiPathResumeTrigger( interrupt_id=interrupt_id, trigger_type=UiPathResumeTriggerType.API, - payload=UiPathConversationToolCallConfirmationValue( - tool_call_id=tool_call_id, - tool_name=approval_tool["name"], - input_schema=approval_tool["input_schema"], - input_value=approval_tool["input"], - ), ), ], ) return - # --- No approval needed — continue with phase 2 inline --- + # --- Continue with phase 2 inline --- # --- Middleware: Summarization (second pass with tool results) --- yield self._node_state("SummarizationMiddleware.before_model", S) diff --git a/pyproject.toml b/pyproject.toml index 1286fbc..98e123c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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", diff --git a/src/uipath/dev/models/chat.py b/src/uipath/dev/models/chat.py index ef14139..920fb96 100644 --- a/src/uipath/dev/models/chat.py +++ b/src/uipath/dev/models/chat.py @@ -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), ) @@ -205,7 +204,6 @@ def get_user_message(user_text: str) -> UiPathConversationMessage: ) ], tool_calls=[], - interrupts=[], role="user", ) diff --git a/src/uipath/dev/models/data.py b/src/uipath/dev/models/data.py index f20f22b..ef30516 100644 --- a/src/uipath/dev/models/data.py +++ b/src/uipath/dev/models/data.py @@ -51,17 +51,3 @@ class ChatData: run_id: str event: UiPathConversationMessageEvent | None = None message: UiPathConversationMessage | None = None - - -@dataclass -class InterruptData: - """Plain data class for HITL interrupt events.""" - - run_id: str - interrupt_id: str - interrupt_type: str # "tool_call_confirmation" | "generic" - tool_call_id: str | None = None - tool_name: str | None = None - input_schema: Any | None = None - input_value: Any | None = None - content: Any | None = None diff --git a/src/uipath/dev/server/__init__.py b/src/uipath/dev/server/__init__.py index 80b3a17..af3d30d 100644 --- a/src/uipath/dev/server/__init__.py +++ b/src/uipath/dev/server/__init__.py @@ -20,7 +20,6 @@ from uipath.dev.models.data import ( ChatData, - InterruptData, LogData, StateData, TraceData, @@ -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, ) @@ -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) diff --git a/src/uipath/dev/server/frontend/src/App.tsx b/src/uipath/dev/server/frontend/src/App.tsx index cf21e22..d05590b 100644 --- a/src/uipath/dev/server/frontend/src/App.tsx +++ b/src/uipath/dev/server/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useRunStore } from "./store/useRunStore"; +import { useRunStore, projectToolCall } from "./store/useRunStore"; import { useAuthStore } from "./store/useAuthStore"; import { useConfigStore } from "./store/useConfigStore"; import { useWebSocket } from "./store/useWebSocket"; @@ -156,10 +156,7 @@ export default function App() { .join("\n") .trim() ?? "", tool_calls: toolCalls.length > 0 - ? toolCalls.map((tc) => ({ - name: (tc.name as string) ?? "", - has_result: !!tc.result, - })) + ? toolCalls.map(projectToolCall) : undefined, }; }); diff --git a/src/uipath/dev/server/frontend/src/api/websocket.ts b/src/uipath/dev/server/frontend/src/api/websocket.ts index 032c1ed..f7510c6 100644 --- a/src/uipath/dev/server/frontend/src/api/websocket.ts +++ b/src/uipath/dev/server/frontend/src/api/websocket.ts @@ -104,8 +104,18 @@ export class WsClient { this.send("chat.message", { run_id: runId, text }); } - sendInterruptResponse(runId: string, data: Record): void { - this.send("chat.interrupt_response", { run_id: runId, data }); + sendConfirmToolCall( + runId: string, + toolCallId: string, + approved: boolean, + input?: unknown, + ): void { + this.send("chat.confirm_tool_call", { + run_id: runId, + tool_call_id: toolCallId, + approved, + input, + }); } debugStep(runId: string): void { diff --git a/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx b/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx deleted file mode 100644 index 5299b6d..0000000 --- a/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx +++ /dev/null @@ -1,547 +0,0 @@ -import { useState, useCallback } from "react"; -import type { InterruptEvent } from "../../types/run"; - -interface Props { - interrupt: InterruptEvent; - onRespond: (data: Record) => void; -} - -/* ── JSON Schema helpers ─────────────────────────────────── */ - -interface SchemaProperty { - type?: string; - description?: string; - enum?: unknown[]; -} - -interface JsonSchema { - properties?: Record; -} - -function hasProperties( - schema: unknown, -): schema is JsonSchema & { properties: Record } { - if (typeof schema !== "object" || schema === null) return false; - const s = schema as Record; - return ( - typeof s.properties === "object" && - s.properties !== null && - Object.keys(s.properties as object).length > 0 - ); -} - -/* ── Per-field form input ────────────────────────────────── */ - -const inputStyle = { - color: "var(--text-primary)", - border: "1px solid var(--border)", - background: "var(--bg-primary)", -}; -const inputClass = - "w-full text-[11px] font-mono py-1 px-2 rounded focus:outline-none"; - -function FormField({ - name, - prop, - value, - onChange, -}: { - name: string; - prop: SchemaProperty; - value: unknown; - onChange: (v: unknown) => void; -}) { - const label = ( - - ); - - if (prop.enum && Array.isArray(prop.enum)) { - return ( -
- {label} - -
- ); - } - - if (prop.type === "boolean") { - return ( -
- {label} - -
- ); - } - - if (prop.type === "number" || prop.type === "integer") { - return ( -
- {label} - - onChange(e.target.value === "" ? null : Number(e.target.value)) - } - step={prop.type === "integer" ? 1 : "any"} - className={inputClass} - style={inputStyle} - /> -
- ); - } - - if (prop.type === "object" || prop.type === "array") { - const str = - typeof value === "string" - ? value - : JSON.stringify(value ?? null, null, 2); - return ( -
- {label} -