diff --git a/pyproject.toml b/pyproject.toml index a0eb682c..8ade0630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-runtime" -version = "0.12.7" +version = "0.13.0" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.28, <0.6.0", + "uipath-core>=0.5.31, <0.6.0", "vaderSentiment>=3.3.2, <4.0", "chardet>=5.2.0, <8.0", ] diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py index 0dedd231..8807ae09 100644 --- a/src/uipath/runtime/__init__.py +++ b/src/uipath/runtime/__init__.py @@ -15,7 +15,7 @@ UiPathStreamNotSupportedError, UiPathStreamOptions, ) -from uipath.runtime.chat.protocol import UiPathChatProtocol +from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol from uipath.runtime.chat.runtime import UiPathChatRuntime from uipath.runtime.context import UiPathRuntimeContext from uipath.runtime.debug.breakpoint import UiPathBreakpointResult @@ -45,6 +45,7 @@ from uipath.runtime.storage import UiPathRuntimeStorageProtocol from uipath.runtime.workspace import ( AttachmentRegistryEntry, + ConversationalWorkspaceRuntime, HydrationPolicy, HydrationRuntime, Workspace, @@ -80,9 +81,11 @@ "UiPathBreakpointResult", "UiPathStreamNotSupportedError", "UiPathResumeTriggerName", + "UiPathChatMetaEventProtocol", "UiPathChatProtocol", "UiPathChatRuntime", "AttachmentRegistryEntry", + "ConversationalWorkspaceRuntime", "HydrationPolicy", "HydrationRuntime", "get_workspace_path", diff --git a/src/uipath/runtime/chat/__init__.py b/src/uipath/runtime/chat/__init__.py index 8261465b..429fbf26 100644 --- a/src/uipath/runtime/chat/__init__.py +++ b/src/uipath/runtime/chat/__init__.py @@ -1,6 +1,6 @@ """Chat bridge protocol and runtime for conversational agents.""" -from uipath.runtime.chat.protocol import UiPathChatProtocol +from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol from uipath.runtime.chat.runtime import UiPathChatRuntime -__all__ = ["UiPathChatProtocol", "UiPathChatRuntime"] +__all__ = ["UiPathChatMetaEventProtocol", "UiPathChatProtocol", "UiPathChatRuntime"] diff --git a/src/uipath/runtime/chat/protocol.py b/src/uipath/runtime/chat/protocol.py index aa7930c5..343e03e1 100644 --- a/src/uipath/runtime/chat/protocol.py +++ b/src/uipath/runtime/chat/protocol.py @@ -1,6 +1,6 @@ """Abstract conversation bridge interface.""" -from typing import Any, Protocol +from typing import Any, Protocol, runtime_checkable from uipath.core.chat import ( UiPathConversationMessageEvent, @@ -70,3 +70,12 @@ async def emit_exchange_error_event(self, error: Exception) -> None: async def wait_for_resume(self) -> dict[str, Any]: """Wait for the interrupt_end event to be received.""" ... + + +@runtime_checkable +class UiPathChatMetaEventProtocol(Protocol): + """Optional chat-bridge capability for conversation metadata events.""" + + async def emit_meta_event(self, meta_event: dict[str, Any]) -> None: + """Send an exchange-scoped conversation metadata event.""" + ... diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index 040040be..9eb31a7f 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -12,10 +12,11 @@ UiPathRuntimeProtocol, UiPathStreamOptions, ) -from uipath.runtime.chat.protocol import UiPathChatProtocol +from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol from uipath.runtime.errors import UiPathBaseRuntimeError from uipath.runtime.errors.contract import UiPathErrorCategory from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, UiPathRuntimeEvent, UiPathRuntimeMessageEvent, ) @@ -86,6 +87,13 @@ async def stream( if isinstance(event, UiPathRuntimeMessageEvent): if event.payload: await self.chat_bridge.emit_message_event(event.payload) + elif isinstance(event, UiPathRuntimeConversationMetaEvent): + if isinstance(self.chat_bridge, UiPathChatMetaEventProtocol): + await self.chat_bridge.emit_meta_event(event.payload) + else: + logger.warning( + "Chat bridge does not support conversation metadata events" + ) if isinstance(event, UiPathRuntimeResult): runtime_result = event diff --git a/src/uipath/runtime/events/__init__.py b/src/uipath/runtime/events/__init__.py index a7aace53..22d13f98 100644 --- a/src/uipath/runtime/events/__init__.py +++ b/src/uipath/runtime/events/__init__.py @@ -14,6 +14,7 @@ UiPathRuntimeStatePhase, ) from uipath.runtime.events.state import ( + UiPathRuntimeConversationMetaEvent, UiPathRuntimeMessageEvent, UiPathRuntimeStateEvent, ) @@ -26,4 +27,5 @@ # Runtime events "UiPathRuntimeStateEvent", "UiPathRuntimeMessageEvent", + "UiPathRuntimeConversationMetaEvent", ] diff --git a/src/uipath/runtime/events/base.py b/src/uipath/runtime/events/base.py index d305e1eb..08176ba2 100644 --- a/src/uipath/runtime/events/base.py +++ b/src/uipath/runtime/events/base.py @@ -10,6 +10,7 @@ class UiPathRuntimeEventType(str, Enum): """Types of events that can be emitted during execution.""" RUNTIME_MESSAGE = "runtime_message" + CONVERSATION_META = "conversation_meta" RUNTIME_STATE = "runtime_state" RUNTIME_ERROR = "runtime_error" RUNTIME_RESULT = "runtime_result" diff --git a/src/uipath/runtime/events/state.py b/src/uipath/runtime/events/state.py index 735fbb6e..33f2c4fd 100644 --- a/src/uipath/runtime/events/state.py +++ b/src/uipath/runtime/events/state.py @@ -40,6 +40,15 @@ class UiPathRuntimeMessageEvent(UiPathRuntimeEvent): ) +class UiPathRuntimeConversationMetaEvent(UiPathRuntimeEvent): + """Conversation metadata emitted by a runtime.""" + + payload: dict[str, Any] + event_type: UiPathRuntimeEventType = Field( + default=UiPathRuntimeEventType.CONVERSATION_META, frozen=True + ) + + class UiPathRuntimeStateEvent(UiPathRuntimeEvent): """Event emitted when agent state is updated. @@ -82,4 +91,8 @@ class UiPathRuntimeStateEvent(UiPathRuntimeEvent): ) -__all__ = ["UiPathRuntimeMessageEvent", "UiPathRuntimeStateEvent"] +__all__ = [ + "UiPathRuntimeConversationMetaEvent", + "UiPathRuntimeMessageEvent", + "UiPathRuntimeStateEvent", +] diff --git a/src/uipath/runtime/workspace/__init__.py b/src/uipath/runtime/workspace/__init__.py index cdf46206..03e8e97e 100644 --- a/src/uipath/runtime/workspace/__init__.py +++ b/src/uipath/runtime/workspace/__init__.py @@ -1,6 +1,7 @@ """Workspace persistence primitives for runtime implementations.""" from uipath.runtime.workspace.context import get_workspace_path +from uipath.runtime.workspace.conversational import ConversationalWorkspaceRuntime from uipath.runtime.workspace.hydration import ( HydrationPolicy, HydrationRuntime, @@ -14,6 +15,7 @@ __all__ = [ "AttachmentRegistryEntry", + "ConversationalWorkspaceRuntime", "HydrationPolicy", "HydrationRuntime", "get_workspace_path", diff --git a/src/uipath/runtime/workspace/conversational.py b/src/uipath/runtime/workspace/conversational.py new file mode 100644 index 00000000..635b95c2 --- /dev/null +++ b/src/uipath/runtime/workspace/conversational.py @@ -0,0 +1,211 @@ +"""Attachment-backed workspace persistence for conversational agents.""" + +import logging +from collections.abc import Mapping +from typing import Any, AsyncGenerator + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, +) +from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.workspace.hydrator import WorkspaceHydrator +from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore + +logger = logging.getLogger(__name__) + +CONVERSATION_META_EVENTS_INPUT_KEY = "uipath__conversation_meta_events" +WORKSPACE_FILES_META_KEY = "workspaceFiles" +WORKSPACE_FILE_PATH_KEY = "path" +WORKSPACE_FILE_ATTACHMENT_KEY = "attachmentKey" + + +class _InvalidWorkspaceSnapshot(ValueError): + pass + + +def _meta_event_payload( + event: Mapping[object, object], +) -> Mapping[object, object] | None: + exchange = event.get("exchange") + if isinstance(exchange, Mapping): + exchange_meta_event = exchange.get("metaEvent") + if isinstance(exchange_meta_event, Mapping): + return exchange_meta_event + + meta_event = event.get("metaEvent") + return meta_event if isinstance(meta_event, Mapping) else None + + +def _attachment_keys_from_meta_events( + input: Mapping[str, object] | None, +) -> dict[str, str] | None: + events = (input or {}).get(CONVERSATION_META_EVENTS_INPUT_KEY) + if not isinstance(events, list): + return None + + for event in reversed(events): + if not isinstance(event, Mapping): + continue + meta_event = _meta_event_payload(event) + if meta_event is None or WORKSPACE_FILES_META_KEY not in meta_event: + continue + + workspace_files = meta_event[WORKSPACE_FILES_META_KEY] + if not isinstance(workspace_files, list): + raise _InvalidWorkspaceSnapshot + + attachment_keys_by_path: dict[str, str] = {} + for workspace_file in workspace_files: + if not isinstance(workspace_file, Mapping): + raise _InvalidWorkspaceSnapshot + path = workspace_file.get(WORKSPACE_FILE_PATH_KEY) + attachment_key = workspace_file.get(WORKSPACE_FILE_ATTACHMENT_KEY) + if not isinstance(path, str) or not isinstance(attachment_key, str): + raise _InvalidWorkspaceSnapshot + attachment_keys_by_path[path] = attachment_key + return attachment_keys_by_path + + return None + + +def _without_conversation_meta_events( + input: dict[str, Any] | None, +) -> dict[str, Any] | None: + if input is None or CONVERSATION_META_EVENTS_INPUT_KEY not in input: + return input + return { + key: value + for key, value in input.items() + if key != CONVERSATION_META_EVENTS_INPUT_KEY + } + + +class ConversationalWorkspaceRuntime: + """Persists workspace attachments between conversational jobs.""" + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + *, + hydrator: WorkspaceHydrator, + registry_store: WorkspaceRegistryStore | None = None, + ): + """Initialize the wrapper with its delegate and hydrator.""" + self.delegate = delegate + self.hydrator = hydrator + self.registry_store = registry_store + self._registry: dict[str, dict[str, Any]] = {} + self._hydrated = False + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute by draining the stream.""" + result: UiPathRuntimeResult | None = None + stream_options = ( + UiPathStreamOptions.model_validate(options.model_dump()) + if options is not None + else None + ) + async for event in self.stream(input, options=stream_options): + if isinstance(event, UiPathRuntimeResult): + result = event + if result is None: + raise RuntimeError("Delegate stream completed without a runtime result") + return result + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Hydrate, stream the delegate, then emit the workspace snapshot.""" + await self._hydrate(input) + delegate_input = _without_conversation_meta_events(input) + final_result: UiPathRuntimeResult | None = None + + async for event in self.delegate.stream(delegate_input, options=options): + if isinstance(event, UiPathRuntimeResult): + final_result = event + else: + yield event + + if final_result is None: + return + if final_result.status == UiPathRuntimeStatus.SUCCESSFUL: + yield await self._dehydrate() + yield final_result + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema from delegate runtime.""" + return await self.delegate.get_schema() + + async def dispose(self) -> None: + """Release resources owned by this wrapper.""" + + async def _hydrate(self, input: Mapping[str, object] | None) -> None: + if self._hydrated: + return + + persisted_registry = ( + await self.registry_store.try_load() if self.registry_store else None + ) + if persisted_registry is not None: + self._registry = persisted_registry + source = "suspended job state" + else: + try: + attachment_keys_by_path = _attachment_keys_from_meta_events(input) + except _InvalidWorkspaceSnapshot: + logger.warning("Ignoring malformed conversational workspace snapshot") + source = "existing workspace" + else: + if attachment_keys_by_path is None: + source = "existing workspace" + else: + source = "conversation metadata" + self._registry = await self.hydrator.hydrate_from_attachments( + attachment_keys_by_path + ) + + logger.info( + "Conversational workspace initialized: %d file(s) from %s", + len(self._registry), + source, + ) + self._hydrated = True + + async def _dehydrate(self) -> UiPathRuntimeConversationMetaEvent: + registry = self._registry + if self.registry_store is not None: + persisted_registry = await self.registry_store.try_load() + if persisted_registry is not None: + registry = persisted_registry + + self._registry = await self.hydrator.dehydrate(registry) + if self.registry_store is not None: + await self.registry_store.save(self._registry) + + workspace_files = [ + { + WORKSPACE_FILE_PATH_KEY: virtual_path, + WORKSPACE_FILE_ATTACHMENT_KEY: entry["attachment_key"], + } + for virtual_path, entry in sorted(self._registry.items()) + ] + logger.info( + "Conversational workspace dehydrate: emitting %d file(s)", + len(workspace_files), + ) + return UiPathRuntimeConversationMetaEvent( + payload={WORKSPACE_FILES_META_KEY: workspace_files} + ) diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py index b3fa1298..d1c04809 100644 --- a/src/uipath/runtime/workspace/hydration.py +++ b/src/uipath/runtime/workspace/hydration.py @@ -160,7 +160,7 @@ async def dispose(self) -> None: async def _hydrate(self) -> None: registry = await self.registry_store.load() - hydrated = await self._get_hydrator().hydrate(registry) + hydrated = await self._get_hydrator().hydrate_from_registry(registry) if hydrated != registry: await self.registry_store.save(hydrated) diff --git a/src/uipath/runtime/workspace/hydrator.py b/src/uipath/runtime/workspace/hydrator.py index fba298ee..26111be8 100644 --- a/src/uipath/runtime/workspace/hydrator.py +++ b/src/uipath/runtime/workspace/hydrator.py @@ -50,7 +50,14 @@ async def hydrate( self, registry: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: - """Download registry files into the workspace. + """Restore a registry using the original public API.""" + return await self.hydrate_from_registry(registry) + + async def hydrate_from_registry( + self, + registry: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Restore registry attachments into the workspace. Files with matching SHA-256 are left untouched. """ @@ -68,6 +75,38 @@ async def hydrate( ) return self._dump_registry(normalized) + async def hydrate_from_attachments( + self, + attachment_keys_by_path: dict[str, str], + ) -> dict[str, dict[str, Any]]: + """Replace the workspace with an attachment snapshot.""" + attachments_by_path: dict[str, UUID] = {} + for virtual_path, raw_attachment_key in attachment_keys_by_path.items(): + normalized_path = self._virtual_path( + self._resolve_workspace_path(virtual_path) + ) + attachments_by_path[normalized_path] = UUID(raw_attachment_key) + + registry: dict[str, AttachmentRegistryEntry] = {} + for virtual_path, parsed_attachment_key in attachments_by_path.items(): + target = self._resolve_workspace_path(virtual_path) + target.parent.mkdir(parents=True, exist_ok=True) + await self.attachments.download_async( + key=parsed_attachment_key, + destination_path=str(target), + folder_key=self.folder_key, + folder_path=self.folder_path, + ) + registry[virtual_path] = AttachmentRegistryEntry( + attachment_key=str(parsed_attachment_key), + sha256=self._sha256(target), + size=target.stat().st_size, + uploaded_at=datetime.now(timezone.utc).isoformat(), + attachment_name=self._attachment_name_for_virtual_path(virtual_path), + ) + self._remove_files_not_in(set(registry)) + return self._dump_registry(registry) + async def dehydrate( self, registry: dict[str, dict[str, Any]], @@ -146,6 +185,11 @@ def _resolve_workspace_path(self, virtual_path: str) -> Path: def _virtual_path(self, path: Path) -> str: return path.relative_to(self.workspace_path).as_posix() + def _remove_files_not_in(self, virtual_paths: set[str]) -> None: + for path in self._iter_files(): + if self._virtual_path(path) not in virtual_paths: + path.unlink() + @staticmethod def _escape(value: str) -> str: # json-pointer escaping; escape ~ before / so they can't collide @@ -173,7 +217,6 @@ def _normalize_registry( ) -> dict[str, AttachmentRegistryEntry]: normalized: dict[str, AttachmentRegistryEntry] = {} for virtual_path, entry in registry.items(): - self._resolve_workspace_path(virtual_path) normalized[virtual_path] = AttachmentRegistryEntry.model_validate(entry) return normalized diff --git a/src/uipath/runtime/workspace/registry_store.py b/src/uipath/runtime/workspace/registry_store.py index d6e1eb49..a494dba2 100644 --- a/src/uipath/runtime/workspace/registry_store.py +++ b/src/uipath/runtime/workspace/registry_store.py @@ -25,11 +25,11 @@ def __init__( self.namespace = namespace self.key = key - async def load(self) -> dict[str, dict[str, Any]]: - """Load registry entries keyed by workspace-relative path.""" + async def try_load(self) -> dict[str, dict[str, Any]] | None: + """Load the registry, or return ``None`` when it has not been saved.""" value = await self.storage.get_value(self.runtime_id, self.namespace, self.key) if value is None: - return {} + return None if not isinstance(value, dict): raise TypeError("Workspace registry payload must be a dictionary.") @@ -39,6 +39,11 @@ async def load(self) -> dict[str, dict[str, Any]]: registry[path] = entry return registry + async def load(self) -> dict[str, dict[str, Any]]: + """Load registry entries, defaulting to an empty registry.""" + registry = await self.try_load() + return registry if registry is not None else {} + async def save(self, registry: dict[str, dict[str, Any]]) -> None: """Persist registry entries.""" await self.storage.set_value( diff --git a/tests/test_chat.py b/tests/test_chat.py index c3be06b3..f17bad53 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -23,23 +23,31 @@ UiPathStreamOptions, ) from uipath.runtime.chat import ( + UiPathChatMetaEventProtocol, UiPathChatProtocol, UiPathChatRuntime, ) from uipath.runtime.chat.runtime import _parse_confirmation -from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeMessageEvent +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, +) from uipath.runtime.schema import UiPathRuntimeSchema -def make_chat_bridge_mock() -> UiPathChatProtocol: +def make_chat_bridge_mock(*, supports_meta_events: bool = True) -> UiPathChatProtocol: """Create a chat bridge mock with all methods that UiPathChatRuntime uses.""" bridge_mock: Mock = Mock(spec=UiPathChatProtocol) bridge_mock.connect = AsyncMock() bridge_mock.disconnect = AsyncMock() bridge_mock.emit_message_event = AsyncMock() + if supports_meta_events: + bridge_mock.emit_meta_event = AsyncMock() bridge_mock.emit_interrupt_event = AsyncMock() bridge_mock.emit_executing_tool_call_event = AsyncMock() + bridge_mock.emit_exchange_end_event = AsyncMock() bridge_mock.wait_for_resume = AsyncMock() return cast(UiPathChatProtocol, bridge_mock) @@ -52,10 +60,12 @@ def __init__( self, messages: Sequence[str], *, + meta_events: Sequence[dict[str, Any]] = (), error_in_stream: bool = False, ) -> None: super().__init__() self.messages: list[str] = list(messages) + self.meta_events = list(meta_events) self.error_in_stream: bool = error_in_stream self.execute_called: bool = False @@ -93,6 +103,9 @@ async def stream( ) yield UiPathRuntimeMessageEvent(payload=message_event) + for meta_event in self.meta_events: + yield UiPathRuntimeConversationMetaEvent(payload=meta_event) + # Final result at the end of streaming yield UiPathRuntimeResult( status=UiPathRuntimeStatus.SUCCESSFUL, @@ -257,6 +270,42 @@ async def test_chat_runtime_stream_yields_all_events(): assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 +@pytest.mark.asyncio +async def test_chat_runtime_emits_conversation_meta_events(): + runtime_impl = StreamingMockRuntime( + messages=[], + meta_events=[{"workspaceFiles": [{"path": "plan.md"}]}], + ) + bridge = make_chat_bridge_mock() + chat_runtime = UiPathChatRuntime(runtime_impl, bridge) + + events = [event async for event in chat_runtime.stream({})] + + meta_event_bridge = cast(UiPathChatMetaEventProtocol, bridge) + cast(AsyncMock, meta_event_bridge.emit_meta_event).assert_awaited_once_with( + {"workspaceFiles": [{"path": "plan.md"}]} + ) + assert isinstance(events[0], UiPathRuntimeConversationMetaEvent) + assert isinstance(events[1], UiPathRuntimeResult) + + +@pytest.mark.asyncio +async def test_chat_runtime_skips_meta_events_for_legacy_bridge(caplog): + runtime_impl = StreamingMockRuntime( + messages=[], + meta_events=[{"workspaceFiles": [{"path": "plan.md"}]}], + ) + bridge = make_chat_bridge_mock(supports_meta_events=False) + chat_runtime = UiPathChatRuntime(runtime_impl, bridge) + + with caplog.at_level("WARNING"): + events = [event async for event in chat_runtime.stream({})] + + assert "does not support conversation metadata events" in caplog.text + assert isinstance(events[0], UiPathRuntimeConversationMetaEvent) + assert isinstance(events[1], UiPathRuntimeResult) + + @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" diff --git a/tests/workspace/test_conversational_workspace.py b/tests/workspace/test_conversational_workspace.py new file mode 100644 index 00000000..46cafd1f --- /dev/null +++ b/tests/workspace/test_conversational_workspace.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any, AsyncGenerator +from unittest.mock import AsyncMock + +import pytest +from uipath.core.chat import UiPathConversationMessageEvent + +from tests.workspace.test_workspace_hydration import FakeAttachments, MemoryStorage +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathExecuteOptions, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, +) +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, +) +from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.workspace.conversational import ( + CONVERSATION_META_EVENTS_INPUT_KEY, + WORKSPACE_FILES_META_KEY, +) + + +class ScriptedRuntime: + def __init__( + self, + events: list[UiPathRuntimeEvent], + result: UiPathRuntimeResult, + workspace_path: Path | None = None, + files: dict[str, str] | None = None, + ) -> None: + self.events = events + self.result = result + self.workspace_path = workspace_path + self.files = files or {} + self.disposed = False + self.received_input: dict[str, Any] | None = None + self.received_options: UiPathStreamOptions | None = None + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + raise NotImplementedError + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + self.received_input = input + self.received_options = options + if self.workspace_path is not None: + for virtual_path, content in self.files.items(): + target = self.workspace_path / virtual_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + for event in self.events: + yield event + yield self.result + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError + + async def dispose(self) -> None: + self.disposed = True + + +def make_runtime( + tmp_path: Path, + delegate: ScriptedRuntime, + attachments: FakeAttachments, + registry_store: WorkspaceRegistryStore | None = None, +) -> tuple[ConversationalWorkspaceRuntime, Workspace]: + workspace = Workspace.create(tmp_path / "workspace") + return ( + ConversationalWorkspaceRuntime( + delegate, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=registry_store, + ), + workspace, + ) + + +def workspace_meta_event( + files: list[tuple[str, uuid.UUID]], + *, + top_level: bool = False, +) -> dict[str, Any]: + meta_event = { + WORKSPACE_FILES_META_KEY: [ + {"path": path, "attachmentKey": str(attachment_key)} + for path, attachment_key in files + ] + } + event: dict[str, Any] = {"conversationId": "conversation-1"} + if top_level: + event["metaEvent"] = meta_event + else: + event["exchange"] = { + "exchangeId": "exchange-1", + "metaEvent": meta_event, + } + return event + + +def meta_events( + events: list[UiPathRuntimeEvent], +) -> list[UiPathRuntimeConversationMetaEvent]: + return [ + event + for event in events + if isinstance(event, UiPathRuntimeConversationMetaEvent) + ] + + +@pytest.mark.asyncio +async def test_success_emits_sorted_workspace_snapshot_before_result( + tmp_path: Path, +) -> None: + attachments = FakeAttachments() + delegate_message = UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent(message_id="message-1") + ) + delegate = ScriptedRuntime( + events=[delegate_message], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + workspace_path=tmp_path / "workspace", + files={"z.txt": "z", "nested/a.txt": "a"}, + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + + events = [event async for event in runtime.stream({"messages": []})] + + assert events[0] is delegate_message + assert isinstance(events[-2], UiPathRuntimeConversationMetaEvent) + assert isinstance(events[-1], UiPathRuntimeResult) + assert [file["path"] for file in events[-2].payload[WORKSPACE_FILES_META_KEY]] == [ + "nested/a.txt", + "z.txt", + ] + assert attachments.uploads == 2 + + +@pytest.mark.asyncio +async def test_empty_workspace_emits_authoritative_snapshot(tmp_path: Path) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments()) + + events = [event async for event in runtime.stream({"messages": []})] + + assert meta_events(events)[0].payload == {WORKSPACE_FILES_META_KEY: []} + + +@pytest.mark.asyncio +async def test_empty_snapshot_removes_stale_workspace_files(tmp_path: Path) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + stale_file = workspace.path / "stale.txt" + stale_file.write_text("stale", encoding="utf-8") + + events = [ + event + async for event in runtime.stream( + { + CONVERSATION_META_EVENTS_INPUT_KEY: [workspace_meta_event([])], + } + ) + ] + + assert not stale_file.exists() + assert meta_events(events)[0].payload == {WORKSPACE_FILES_META_KEY: []} + assert attachments.uploads == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "workspace_files", + [ + "invalid", + [{"path": "restored.txt"}], + [ + {"path": "restored.txt", "attachmentKey": str(uuid.uuid4())}, + {"attachmentKey": str(uuid.uuid4())}, + ], + ], +) +async def test_malformed_snapshot_does_not_remove_workspace_files( + tmp_path: Path, + workspace_files: object, +) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + existing_file = workspace.path / "existing.txt" + existing_file.write_text("keep", encoding="utf-8") + + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + {"exchange": {"metaEvent": {WORKSPACE_FILES_META_KEY: workspace_files}}} + ] + } + async for _ in runtime.stream(input): + pass + + assert existing_file.read_text(encoding="utf-8") == "keep" + assert attachments.downloads == 0 + assert attachments.uploads == 0 + + +@pytest.mark.asyncio +async def test_reserved_meta_events_are_not_forwarded_to_delegate( + tmp_path: Path, +) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments()) + input = { + "messages": [], + "custom": "value", + CONVERSATION_META_EVENTS_INPUT_KEY: [], + } + + async for _ in runtime.stream(input): + pass + + assert delegate.received_input == {"messages": [], "custom": "value"} + assert input[CONVERSATION_META_EVENTS_INPUT_KEY] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("top_level", [False, True]) +async def test_hydrates_single_workspace_snapshot( + tmp_path: Path, + top_level: bool, +) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("latest.txt", b"latest") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event( + [("nested/latest.txt", attachment_key)], top_level=top_level + ) + ] + } + + async for _ in runtime.stream(input): + pass + + assert (workspace.path / "nested/latest.txt").read_bytes() == b"latest" + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("top_level", [False, True]) +async def test_hydrates_latest_workspace_snapshot( + tmp_path: Path, + top_level: bool, +) -> None: + attachments = FakeAttachments() + old_key = uuid.uuid4() + latest_key = uuid.uuid4() + attachments.files[old_key] = ("old.txt", b"old") + attachments.files[latest_key] = ("latest.txt", b"latest") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + "messages": [], + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("old.txt", old_key)]), + workspace_meta_event( + [("nested/latest.txt", latest_key)], top_level=top_level + ), + ], + } + + async for _ in runtime.stream(input): + pass + + assert not (workspace.path / "old.txt").exists() + assert (workspace.path / "nested/latest.txt").read_bytes() == b"latest" + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +async def test_latest_empty_snapshot_does_not_restore_older_files( + tmp_path: Path, +) -> None: + attachments = FakeAttachments() + old_key = uuid.uuid4() + attachments.files[old_key] = ("old.txt", b"old") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("old.txt", old_key)]), + workspace_meta_event([]), + ] + } + + async for _ in runtime.stream(input): + pass + + assert not (workspace.path / "old.txt").exists() + assert attachments.downloads == 0 + + +@pytest.mark.asyncio +async def test_persisted_empty_registry_wins_over_conversation_metadata( + tmp_path: Path, +) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("old.txt", b"old") + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + await store.save({}) + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments, store) + + async for _ in runtime.stream( + { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("old.txt", attachment_key)]) + ] + } + ): + pass + + assert not (workspace.path / "old.txt").exists() + assert attachments.downloads == 0 + + +@pytest.mark.asyncio +async def test_hydration_runtime_owns_persisted_registry_hydration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments = FakeAttachments() + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + await store.save({}) + workspace = Workspace.create(tmp_path / "workspace") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + hydrate_from_registry = AsyncMock(wraps=hydrator.hydrate_from_registry) + monkeypatch.setattr( + hydrator, + "hydrate_from_registry", + hydrate_from_registry, + ) + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + hydration_runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=hydrator, + registry_store=store, + ) + runtime = ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + registry_store=store, + ) + + async for _ in runtime.stream({}): + pass + + hydrate_from_registry.assert_awaited_once_with({}) + + +@pytest.mark.asyncio +async def test_faulted_result_does_not_emit_or_upload_snapshot(tmp_path: Path) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED), + workspace_path=tmp_path / "workspace", + files={"notes.txt": "hello"}, + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + + events = [event async for event in runtime.stream({})] + + assert meta_events(events) == [] + assert attachments.uploads == 0 + assert isinstance(events[-1], UiPathRuntimeResult) + + +@pytest.mark.asyncio +async def test_execute_preserves_options(tmp_path: Path) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments()) + options = UiPathExecuteOptions(resume=True, breakpoints=["node-1"]) + + await runtime.execute({}, options=options) + + assert delegate.received_options is not None + assert delegate.received_options.resume is True + assert delegate.received_options.breakpoints == ["node-1"] + + +@pytest.mark.asyncio +async def test_hydration_only_runs_once_across_resume_passes(tmp_path: Path) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("todo.md", b"step 1") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("todo.md", attachment_key)]) + ] + } + + async for _ in runtime.stream(input): + pass + async for _ in runtime.stream({"interrupt-1": {"approved": True}}): + pass + + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +async def test_failed_hydration_can_be_retried( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("todo.md", b"step 1") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("todo.md", attachment_key)]) + ] + } + download = attachments.download_async + + async def fail_download(**_: Any) -> str: + raise RuntimeError("download failed") + + monkeypatch.setattr(attachments, "download_async", fail_download) + with pytest.raises(RuntimeError, match="download failed"): + async for _ in runtime.stream(input): + pass + + monkeypatch.setattr(attachments, "download_async", download) + async for _ in runtime.stream(input): + pass + + assert (workspace.path / "todo.md").read_bytes() == b"step 1" + + +@pytest.mark.asyncio +async def test_attachment_upload_failure_propagates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + workspace_path=tmp_path / "workspace", + files={"plan.md": "plan"}, + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + + async def fail_upload(**_: Any) -> uuid.UUID: + raise RuntimeError("upload failed") + + monkeypatch.setattr(attachments, "upload_async", fail_upload) + + with pytest.raises(RuntimeError, match="upload failed"): + async for _ in runtime.stream({}): + pass + + +@pytest.mark.asyncio +async def test_dispose_does_not_dispose_injected_dependencies(tmp_path: Path) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + workspace = Workspace.create(tmp_path / "workspace", cleanup=True) + runtime = ConversationalWorkspaceRuntime( + delegate, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + ) + + await runtime.dispose() + + assert not delegate.disposed + assert workspace.path.exists() + + await delegate.dispose() + await workspace.dispose() diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py index 82567e2b..74e52bbc 100644 --- a/tests/workspace/test_workspace_hydration.py +++ b/tests/workspace/test_workspace_hydration.py @@ -7,8 +7,13 @@ from typing import Any, AsyncGenerator import pytest +from uipath.core.chat import ( + UiPathConversationMessageEndEvent, + UiPathConversationMessageEvent, +) from uipath.runtime import ( + ConversationalWorkspaceRuntime, HydrationPolicy, HydrationRuntime, UiPathExecuteOptions, @@ -21,7 +26,12 @@ get_workspace_path, ) from uipath.runtime.errors import UiPathErrorCategory, UiPathRuntimeError -from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeStateEvent +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, + UiPathRuntimeStateEvent, +) from uipath.runtime.schema import UiPathRuntimeSchema @@ -110,6 +120,18 @@ async def link_attachment_async( self.links.append((job_key, attachment_key)) +@pytest.mark.asyncio +async def test_registry_store_distinguishes_missing_from_saved_empty() -> None: + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + + assert await store.try_load() is None + + await store.save({}) + + assert await store.try_load() == {} + assert await store.load() == {} + + class WritingRuntime: def __init__(self, workspace_path: Path, status: UiPathRuntimeStatus) -> None: self.workspace_path = workspace_path @@ -235,6 +257,35 @@ async def access_workspace_after_final_result() -> Path: yield UiPathRuntimeResult(status=self.status, output={"ok": True}) +class ReadingRuntime(WritingRuntime): + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + assert (self.workspace_path / "notes.txt").read_text( + encoding="utf-8" + ) == "hello" + (self.workspace_path / "notes.txt").write_text( + "hello after resume", encoding="utf-8" + ) + return UiPathRuntimeResult(status=self.status, output={"ok": True}) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + message_id = "assistant-1" + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + end=UiPathConversationMessageEndEvent(), + ) + ) + yield await self.execute(input, options) + + @pytest.mark.asyncio async def test_dehydrate_uploads_changed_files_and_saves_registry( tmp_path: Path, @@ -623,6 +674,37 @@ def test_hydration_runtime_requires_exactly_one_hydrator_source( ) +@pytest.mark.asyncio +async def test_conversational_resume_reuses_registry_persisted_on_suspend( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + delegate = WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED) + hydration_runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=hydrator, + registry_store=store, + ) + runtime = ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + registry_store=store, + ) + + await runtime.execute({}) + delegate.status = UiPathRuntimeStatus.SUCCESSFUL + await runtime.execute({}, options=UiPathExecuteOptions(resume=True)) + + assert attachments.uploads == 1 + + @pytest.mark.asyncio async def test_successful_completion_persists_when_policy_allows( tmp_path: Path, @@ -648,7 +730,9 @@ async def test_successful_completion_persists_when_policy_allows( @pytest.mark.asyncio -async def test_hydrate_downloads_missing_registry_files(tmp_path: Path) -> None: +async def test_hydrate_compatibility_api_downloads_registry_files( + tmp_path: Path, +) -> None: workspace = Workspace.create(tmp_path / "workspace") attachments = FakeAttachments() key = uuid.uuid4() @@ -698,7 +782,68 @@ async def test_stream_persists_on_suspend(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_hydrate_skips_unchanged_local_file(tmp_path: Path) -> None: +async def test_conversational_suspend_restores_in_new_workspace(tmp_path: Path) -> None: + attachments = FakeAttachments() + storage = MemoryStorage() + first_workspace = Workspace.create(tmp_path / "first", cleanup=False) + first_hydrator = WorkspaceHydrator( + workspace_path=first_workspace.path, + attachments=attachments, + ) + suspended_runtime = HydrationRuntime( + WritingRuntime(first_workspace.path, UiPathRuntimeStatus.SUSPENDED), + workspace=first_workspace, + hydrator=first_hydrator, + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + ) + conversation_runtime = ConversationalWorkspaceRuntime( + suspended_runtime, + hydrator=first_hydrator, + ) + + events = [event async for event in conversation_runtime.stream({"messages": []})] + + assert isinstance(events[-1], UiPathRuntimeResult) + assert attachments.uploads == 1 + + second_workspace = Workspace.create(tmp_path / "second", cleanup=False) + second_hydrator = WorkspaceHydrator( + workspace_path=second_workspace.path, + attachments=attachments, + ) + second_registry_store = WorkspaceRegistryStore(storage, "runtime-1") + resumed_runtime = HydrationRuntime( + ReadingRuntime(second_workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=second_workspace, + hydrator=second_hydrator, + registry_store=second_registry_store, + ) + resumed_conversation_runtime = ConversationalWorkspaceRuntime( + resumed_runtime, + hydrator=second_hydrator, + registry_store=second_registry_store, + ) + + resumed_events = [ + event async for event in resumed_conversation_runtime.stream({"messages": []}) + ] + + assert isinstance(resumed_events[-1], UiPathRuntimeResult) + assert attachments.downloads == 1 + assert attachments.uploads == 2 + workspace_snapshots = [ + event.payload + for event in resumed_events + if isinstance(event, UiPathRuntimeConversationMetaEvent) + ] + assert len(workspace_snapshots) == 1 + assert workspace_snapshots[0]["workspaceFiles"][0]["path"] == "notes.txt" + + +@pytest.mark.asyncio +async def test_hydrate_from_registry_skips_unchanged_local_file( + tmp_path: Path, +) -> None: workspace = Workspace.create(tmp_path / "workspace") attachments = FakeAttachments() (workspace.path / "notes.txt").write_text("same", encoding="utf-8") @@ -717,7 +862,7 @@ async def test_hydrate_skips_unchanged_local_file(tmp_path: Path) -> None: } } - await hydrator.hydrate(registry) + await hydrator.hydrate_from_registry(registry) assert attachments.downloads == 0 diff --git a/uv.lock b/uv.lock index ec486156..43a3b2eb 100644 --- a/uv.lock +++ b/uv.lock @@ -1139,21 +1139,21 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.28" +version = "0.5.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/f9/8d2f1d98cbebbcf059cf4561f38f34ad4cd58423e4f15cad22bd297a2563/uipath_core-0.5.28.tar.gz", hash = "sha256:942987f6b612c64f93d612ad7b242276ed75f129fdd8f25bc71c24ec8887e388", size = 130578, upload-time = "2026-06-30T14:04:48.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/518fcb7d466a39c675194c492e8accbffdc7fd6b280d89b21ed11700a7bd/uipath_core-0.5.31.tar.gz", hash = "sha256:6abb443582d9a8ab6a504791296d49f524fb430f66d8dfaf6d6d1849958700bb", size = 130979, upload-time = "2026-07-15T06:56:03.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1e/385bb166232a57ebe938cc57ad2717f350bc922bb5d2ce31af84306b7569/uipath_core-0.5.28-py3-none-any.whl", hash = "sha256:b952a46a21710073cbc16d6d5684e9aa645c107f57a636b778cfb94aa81a1e48", size = 54980, upload-time = "2026-06-30T14:04:47.374Z" }, + { url = "https://files.pythonhosted.org/packages/1a/37/47a4e12bc7bdcfab4bd4e8e1f2a5c083bd59fca42fd431026a3f14c642a3/uipath_core-0.5.31-py3-none-any.whl", hash = "sha256:3dff5ecb236bf46af683c34d79bb2cf458a942ec041e1cbf7b4825ae246ff00c", size = 55040, upload-time = "2026-07-15T06:56:02.446Z" }, ] [[package]] name = "uipath-runtime" -version = "0.12.7" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "chardet" }, @@ -1179,7 +1179,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, - { name = "uipath-core", specifier = ">=0.5.28,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.31,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ]