diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 58e37a42a..dab4b8d7c 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -130,6 +130,55 @@ jobs: working-directory: packages/uipath-platform run: uv run pytest + e2e-uipath-platform: + name: E2E (uipath-platform, memory) + needs: detect-changed-packages + runs-on: ubuntu-latest + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-platform")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath-platform" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv sync --all-extras --python 3.11 + + - name: Run E2E memory tests + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + env: + UIPATH_URL: ${{ secrets.ALPHA_BASE_URL }} + UIPATH_CLIENT_ID: ${{ secrets.ALPHA_TEST_CLIENT_ID }} + UIPATH_CLIENT_SECRET: ${{ secrets.ALPHA_TEST_CLIENT_SECRET }} + UIPATH_FOLDER_KEY: ${{ secrets.UIPATH_MEMORY_FOLDER }} + run: uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v --no-cov + test-uipath: name: Test (uipath, ${{ matrix.python-version }}, ${{ matrix.os }}) needs: detect-changed-packages @@ -184,7 +233,7 @@ jobs: test-gate: name: Test - needs: [test-uipath-core, test-uipath-platform, test-uipath] + needs: [test-uipath-core, test-uipath-platform, test-uipath, e2e-uipath-platform] runs-on: ubuntu-latest if: always() steps: @@ -196,4 +245,8 @@ jobs: echo "Tests failed" exit 1 fi + # E2E tests are informational — log but don't block + if [[ "${{ needs.e2e-uipath-platform.result }}" == "failure" ]]; then + echo "⚠️ E2E memory tests failed (non-blocking)" + fi echo "All tests passed" diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index c113a5a6b..47b5a9596 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.13" +version = "0.1.14" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -98,9 +98,12 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +addopts = "-ra -q --cov=src/uipath --cov-report=term-missing -m 'not e2e'" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests against real ECS/LLMOps (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY)", +] [tool.coverage.report] show_missing = true diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 87c3a17f0..4697aef33 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -22,6 +22,7 @@ from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError from .guardrails import GuardrailsService +from .memory import MemoryService from .orchestrator import ( AssetsService, AttachmentsService, @@ -113,6 +114,10 @@ def context_grounding(self) -> ContextGroundingService: self.buckets, ) + @property + def memory(self) -> MemoryService: + return MemoryService(self._config, self._execution_context, self.folders) + @property def documents(self) -> DocumentsService: return DocumentsService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py new file mode 100644 index 000000000..d00769e74 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -0,0 +1,47 @@ +"""Init file for memory module.""" + +from ._memory_service import MemoryService +from .memory import ( + EpisodicMemoryCreateRequest, + EpisodicMemoryField, + EpisodicMemoryIndex, + EpisodicMemoryListResponse, + EpisodicMemoryPatchRequest, + EpisodicMemoryStatus, + FeedbackMemoryStatus, + FieldSettings, + MemoryIngestRequest, + MemoryIngestResponse, + MemoryItemResponse, + MemoryItemUpdateRequest, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + SearchField, + SearchMode, + SearchSettings, +) + +__all__ = [ + "EpisodicMemoryCreateRequest", + "EpisodicMemoryField", + "EpisodicMemoryIndex", + "EpisodicMemoryListResponse", + "EpisodicMemoryPatchRequest", + "EpisodicMemoryStatus", + "FeedbackMemoryStatus", + "FieldSettings", + "MemoryIngestRequest", + "MemoryIngestResponse", + "MemoryItemResponse", + "MemoryItemUpdateRequest", + "MemoryMatch", + "MemoryMatchField", + "MemorySearchRequest", + "MemorySearchResponse", + "MemoryService", + "SearchField", + "SearchMode", + "SearchSettings", +] diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py new file mode 100644 index 000000000..69a8bcdef --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -0,0 +1,577 @@ +"""Episodic Memory service. + +Index management (create/list/get/delete) goes through ECS v2. +Ingest and search go through LLMOps, which enriches traces/feedback +before forwarding to ECS. +""" + +from typing import Any, Optional + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from .memory import ( + EpisodicMemoryCreateRequest, + EpisodicMemoryIndex, + EpisodicMemoryListResponse, + FeedbackMemoryStatus, + MemoryIngestRequest, + MemoryIngestResponse, + MemoryItemResponse, + MemoryItemUpdateRequest, + MemorySearchRequest, + MemorySearchResponse, +) + +_ECS_BASE = "/ecs_/v2/episodicmemories" +_LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" +_LLMOPS_MEMORY_BASE = "/llmopstenant_/api/Memory" + + +class MemoryService(FolderContext, BaseService): + """Service for Agent Episodic Memory. + + Agent Memory allows agents to persist context across jobs using dynamic + few-shot retrieval. Memory indexes are folder-scoped and managed via ECS. + Ingestion and search are routed through LLMOps, which handles + trace/feedback enrichment and system prompt injection. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + # ── Index operations (ECS) ───────────────────────────────────────── + + @traced(name="memory_create", run_type="uipath") + def create( + self, + name: str, + description: Optional[str] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + ) -> EpisodicMemoryIndex: + """Create a new episodic memory index. + + Args: + name: The name of the memory index (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the index should be encrypted. + folder_key: The folder key for the operation. + + Returns: + EpisodicMemoryIndex: The created memory index. + """ + spec = self._create_spec(name, description, is_encrypted, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + return EpisodicMemoryIndex.model_validate(response) + + @traced(name="memory_create", run_type="uipath") + async def create_async( + self, + name: str, + description: Optional[str] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + ) -> EpisodicMemoryIndex: + """Asynchronously create a new episodic memory index.""" + spec = self._create_spec(name, description, is_encrypted, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + ).json() + return EpisodicMemoryIndex.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + def list( + self, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + folder_key: Optional[str] = None, + ) -> EpisodicMemoryListResponse: + """List episodic memory indexes with optional OData query parameters. + + Args: + filter: OData $filter expression. + orderby: OData $orderby expression. + top: Maximum number of results. + skip: Number of results to skip. + folder_key: The folder key for the operation. + + Returns: + EpisodicMemoryListResponse: The list of memory indexes. + """ + spec = self._list_spec(filter, orderby, top, skip, folder_key) + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + return EpisodicMemoryListResponse.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + async def list_async( + self, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + folder_key: Optional[str] = None, + ) -> EpisodicMemoryListResponse: + """Asynchronously list episodic memory indexes.""" + spec = self._list_spec(filter, orderby, top, skip, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + return EpisodicMemoryListResponse.model_validate(response) + + @traced(name="memory_get", run_type="uipath") + def get( + self, + key: str, + folder_key: Optional[str] = None, + ) -> EpisodicMemoryIndex: + """Get a single episodic memory index by ID. + + Args: + key: The GUID of the memory index. + folder_key: The folder key for the operation. + + Returns: + EpisodicMemoryIndex: The memory index. + """ + spec = self._get_spec(key, folder_key) + response = self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ).json() + return EpisodicMemoryIndex.model_validate(response) + + @traced(name="memory_get", run_type="uipath") + async def get_async( + self, + key: str, + folder_key: Optional[str] = None, + ) -> EpisodicMemoryIndex: + """Asynchronously get a single episodic memory index by ID.""" + spec = self._get_spec(key, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + ).json() + return EpisodicMemoryIndex.model_validate(response) + + @traced(name="memory_delete_index", run_type="uipath") + def delete_index( + self, + key: str, + folder_key: Optional[str] = None, + ) -> None: + """Delete an episodic memory index. + + Args: + key: The GUID of the memory index. + folder_key: The folder key for the operation. + """ + spec = self._delete_index_spec(key, folder_key) + self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + @traced(name="memory_delete_index", run_type="uipath") + async def delete_index_async( + self, + key: str, + folder_key: Optional[str] = None, + ) -> None: + """Asynchronously delete an episodic memory index.""" + spec = self._delete_index_spec(key, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + # ── Ingest (LLMOps) ─────────────────────────────────────────────── + + @traced(name="memory_ingest", run_type="uipath") + def ingest( + self, + memory_space_id: str, + feedback_id: str, + memory_space_name: Optional[str] = None, + attributes: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> MemoryIngestResponse: + """Ingest a memory item via LLMOps. + + LLMOps extracts fields from the trace/feedback and forwards + the ingestion to ECS. + + Args: + memory_space_id: The GUID of the memory space (ECS index). + feedback_id: The GUID of the feedback to ingest from. + memory_space_name: Optional name for the memory space. + attributes: Optional JSON-encoded attributes. + folder_key: The folder key for the operation. + + Returns: + MemoryIngestResponse: The ID of the created memory item. + """ + spec = self._ingest_spec(memory_space_id, memory_space_name, folder_key) + body = MemoryIngestRequest(feedback_id=feedback_id, attributes=attributes) + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + json=body.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return MemoryIngestResponse.model_validate(response) + + @traced(name="memory_ingest", run_type="uipath") + async def ingest_async( + self, + memory_space_id: str, + feedback_id: str, + memory_space_name: Optional[str] = None, + attributes: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> MemoryIngestResponse: + """Asynchronously ingest a memory item via LLMOps.""" + spec = self._ingest_spec(memory_space_id, memory_space_name, folder_key) + body = MemoryIngestRequest(feedback_id=feedback_id, attributes=attributes) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + json=body.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + ).json() + return MemoryIngestResponse.model_validate(response) + + # ── Search (LLMOps) ─────────────────────────────────────────────── + + @traced(name="memory_search", run_type="uipath") + def search( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> MemorySearchResponse: + """Search episodic memory via LLMOps. + + Returns search results with scores and a systemPromptInjection + string ready for the agent loop. + + Args: + memory_space_id: The GUID of the memory space (ECS index). + request: The search request payload. + folder_key: The folder key for the operation. + + Returns: + MemorySearchResponse: Results, metadata, and system prompt injection. + """ + spec = self._search_spec(memory_space_id, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return MemorySearchResponse.model_validate(response) + + @traced(name="memory_search", run_type="uipath") + async def search_async( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> MemorySearchResponse: + """Asynchronously search episodic memory via LLMOps.""" + spec = self._search_spec(memory_space_id, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + ).json() + return MemorySearchResponse.model_validate(response) + + # ── Memory item operations (LLMOps) ─────────────────────────────── + + @traced(name="memory_patch", run_type="uipath") + def patch_memory( + self, + memory_space_id: str, + memory_item_id: str, + status: FeedbackMemoryStatus, + folder_key: Optional[str] = None, + ) -> MemoryItemResponse: + """Update a memory item's status (Enabled/Disabled) via LLMOps. + + Args: + memory_space_id: The GUID of the memory space. + memory_item_id: The GUID of the memory item. + status: The new status. + folder_key: The folder key for the operation. + + Returns: + MemoryItemResponse: The updated memory item. + """ + spec = self._patch_memory_spec(memory_space_id, memory_item_id, folder_key) + body = MemoryItemUpdateRequest(status=status) + response = self.request( + spec.method, + spec.endpoint, + json=body.model_dump(by_alias=True), + headers=spec.headers, + ).json() + return MemoryItemResponse.model_validate(response) + + @traced(name="memory_patch", run_type="uipath") + async def patch_memory_async( + self, + memory_space_id: str, + memory_item_id: str, + status: FeedbackMemoryStatus, + folder_key: Optional[str] = None, + ) -> MemoryItemResponse: + """Asynchronously update a memory item's status via LLMOps.""" + spec = self._patch_memory_spec(memory_space_id, memory_item_id, folder_key) + body = MemoryItemUpdateRequest(status=status) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=body.model_dump(by_alias=True), + headers=spec.headers, + ) + ).json() + return MemoryItemResponse.model_validate(response) + + @traced(name="memory_delete", run_type="uipath") + def delete_memory( + self, + memory_space_id: str, + memory_item_id: str, + folder_key: Optional[str] = None, + ) -> None: + """Delete a memory item by ID via LLMOps. + + Args: + memory_space_id: The GUID of the memory space. + memory_item_id: The GUID of the memory item. + folder_key: The folder key for the operation. + """ + spec = self._delete_memory_spec(memory_space_id, memory_item_id, folder_key) + self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + @traced(name="memory_delete", run_type="uipath") + async def delete_memory_async( + self, + memory_space_id: str, + memory_item_id: str, + folder_key: Optional[str] = None, + ) -> None: + """Asynchronously delete a memory item by ID via LLMOps.""" + spec = self._delete_memory_spec(memory_space_id, memory_item_id, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + # ── Private spec builders ───────────────────────────────────────── + + def _resolve_folder( + self, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Resolve the folder key, supporting folder_path lookup for serverless. + + Priority: + 1. Explicit folder_key argument + 2. Explicit folder_path argument → resolve via FolderService + 3. UIPATH_FOLDER_KEY env var (via FolderContext._folder_key) + 4. UIPATH_FOLDER_PATH env var → resolve via FolderService + """ + if folder_key is None and folder_path is not None: + folder_key = self._folders_service.retrieve_key(folder_path=folder_path) + + if folder_key is None and folder_path is None: + folder_key = self._folder_key or ( + self._folders_service.retrieve_key(folder_path=self._folder_path) + if self._folder_path + else None + ) + + return folder_key + + # -- ECS specs -- + + def _create_spec( + self, + name: str, + description: Optional[str], + is_encrypted: Optional[bool], + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + body = EpisodicMemoryCreateRequest( + name=name, + description=description, + is_encrypted=is_encrypted, + ) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_ECS_BASE}/create"), + json=body.model_dump(by_alias=True, exclude_none=True), + headers={**header_folder(folder_key, None)}, + ) + + def _list_spec( + self, + filter: Optional[str], + orderby: Optional[str], + top: Optional[int], + skip: Optional[int], + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + params: dict[str, Any] = {} + if filter is not None: + params["$filter"] = filter + if orderby is not None: + params["$orderby"] = orderby + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + return RequestSpec( + method="GET", + endpoint=Endpoint(_ECS_BASE), + params=params, + headers={**header_folder(folder_key, None)}, + ) + + def _get_spec(self, key: str, folder_key: Optional[str] = None) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="GET", + endpoint=Endpoint(f"{_ECS_BASE}/{key}"), + headers={**header_folder(folder_key, None)}, + ) + + def _delete_index_spec( + self, key: str, folder_key: Optional[str] = None + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"{_ECS_BASE}/{key}"), + headers={**header_folder(folder_key, None)}, + ) + + # -- LLMOps specs -- + + def _ingest_spec( + self, + memory_space_id: str, + memory_space_name: Optional[str], + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + params: dict[str, Any] = {} + if memory_space_name is not None: + params["memorySpaceName"] = memory_space_name + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/ingest"), + params=params, + headers={**header_folder(folder_key, None)}, + ) + + def _search_spec( + self, + memory_space_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/search"), + headers={**header_folder(folder_key, None)}, + ) + + def _patch_memory_spec( + self, + memory_space_id: str, + memory_item_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="PATCH", + endpoint=Endpoint( + f"{_LLMOPS_MEMORY_BASE}/{memory_space_id}/items/{memory_item_id}" + ), + headers={**header_folder(folder_key, None)}, + ) + + def _delete_memory_spec( + self, + memory_space_id: str, + memory_item_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="DELETE", + endpoint=Endpoint( + f"{_LLMOPS_MEMORY_BASE}/{memory_space_id}/items/{memory_item_id}" + ), + headers={**header_folder(folder_key, None)}, + ) diff --git a/packages/uipath-platform/src/uipath/platform/memory/memory.py b/packages/uipath-platform/src/uipath/platform/memory/memory.py new file mode 100644 index 000000000..3cb9f09c0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -0,0 +1,226 @@ +"""Pydantic models for the Episodic Memory API. + +Index management goes through ECS v2. Ingest and search go through LLMOps, +which enriches traces/feedback before forwarding to ECS. +""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# ── Enums ────────────────────────────────────────────────────────────── + + +class SearchMode(str, Enum): + """Search mode for episodic memory queries.""" + + Hybrid = "Hybrid" + Semantic = "Semantic" + + +class EpisodicMemoryStatus(str, Enum): + """Status of an individual memory record (ECS).""" + + active = "active" + inactive = "inactive" + + +class FeedbackMemoryStatus(str, Enum): + """Status of a memory item (LLMOps).""" + + Enabled = "Enabled" + Disabled = "Disabled" + + +# ── Shared field models (used by both ECS and LLMOps) ───────────────── + + +class EpisodicMemoryField(BaseModel): + """A field with a key path and value, used in ECS ingest requests.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath", min_length=1) + value: str = Field(..., alias="value", min_length=1) + + +class FieldSettings(BaseModel): + """Per-field search settings (optional overrides).""" + + model_config = ConfigDict(populate_by_name=True) + + weight: float = Field(default=1.0, alias="weight", ge=0.0, le=1.0) + threshold: Optional[float] = Field(None, alias="threshold", ge=0.0, le=1.0) + search_mode: Optional[SearchMode] = Field(None, alias="searchMode") + + +class SearchField(BaseModel): + """A field in a search request, with per-field settings.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath", min_length=1) + value: str = Field(..., alias="value", min_length=1) + settings: FieldSettings = Field(default_factory=FieldSettings, alias="settings") + + +class SearchSettings(BaseModel): + """Top-level search settings.""" + + model_config = ConfigDict(populate_by_name=True) + + threshold: float = Field(default=0.0, alias="threshold", ge=0.0, le=1.0) + result_count: int = Field(default=1, alias="resultCount", ge=1, le=10) + search_mode: SearchMode = Field(..., alias="searchMode") + + +class MemoryMatchField(BaseModel): + """A field within a search result, with scoring details.""" + + model_config = ConfigDict(populate_by_name=True) + + key_path: List[str] = Field(..., alias="keyPath") + value: str = Field(..., alias="value") + weight: float = Field(..., alias="weight") + score: float = Field(..., alias="score") + semantic_score: float = Field(..., alias="semanticScore") + weighted_score: float = Field(..., alias="weightedScore") + + +# ── ECS request models (index CRUD) ─────────────────────────────────── + + +class EpisodicMemoryCreateRequest(BaseModel): + """Request payload for creating an episodic memory index (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., alias="name", max_length=128, min_length=1) + description: Optional[str] = Field(None, alias="description", max_length=1024) + is_encrypted: Optional[bool] = Field(None, alias="isEncrypted") + + +class EpisodicMemoryPatchRequest(BaseModel): + """Request payload for updating a memory item's status (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + status: EpisodicMemoryStatus = Field(..., alias="status") + + +# ── ECS response models ─────────────────────────────────────────────── + + +class EpisodicMemoryIndex(BaseModel): + """An episodic memory index (folder-scoped, from ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., alias="id") + name: str = Field(..., alias="name") + description: Optional[str] = Field(None, alias="description") + last_queried: Optional[str] = Field(None, alias="lastQueried") + memories_count: int = Field(default=0, alias="memoriesCount") + folder_key: str = Field(..., alias="folderKey") + created_by_user_id: Optional[str] = Field(None, alias="createdByUserId") + is_encrypted: bool = Field(default=False, alias="isEncrypted") + + +class EpisodicMemoryListResponse(BaseModel): + """OData response from listing episodic memory indexes (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + value: List[EpisodicMemoryIndex] = Field(default_factory=list, alias="value") + + +# ── LLMOps ingest models ────────────────────────────────────────────── + + +class MemoryIngestRequest(BaseModel): + """Request payload for ingesting a memory via LLMOps Agent endpoint. + + LLMOps extracts fields from the trace/feedback and forwards to ECS. + """ + + model_config = ConfigDict(populate_by_name=True) + + feedback_id: str = Field(..., alias="feedbackId") + attributes: Optional[str] = Field(None, alias="attributes") + + +class MemoryIngestResponse(BaseModel): + """Response from LLMOps ingest, containing the new memory item ID.""" + + model_config = ConfigDict(populate_by_name=True) + + memory_item_id: str = Field(..., alias="memoryItemId") + + +# ── LLMOps search models ────────────────────────────────────────────── + + +class MemorySearchRequest(BaseModel): + """Request payload for searching memory via LLMOps. + + Includes definitionSystemPrompt so LLMOps can generate the + systemPromptInjection for the agent loop. + """ + + model_config = ConfigDict(populate_by_name=True) + + fields: List[SearchField] = Field(..., alias="fields", min_length=1, max_length=20) + settings: SearchSettings = Field(..., alias="settings") + definition_system_prompt: Optional[str] = Field( + None, alias="definitionSystemPrompt" + ) + + +class MemoryMatch(BaseModel): + """A single matched memory from a search operation (LLMOps).""" + + model_config = ConfigDict(populate_by_name=True) + + memory_item_id: str = Field(..., alias="memoryItemId") + score: float = Field(..., alias="score") + semantic_score: float = Field(..., alias="semanticScore") + weighted_score: float = Field(..., alias="weightedScore") + fields: List[MemoryMatchField] = Field(..., alias="fields") + span: Optional[Any] = Field(None, alias="span") + feedback: Optional[Any] = Field(None, alias="feedback") + + +class MemorySearchResponse(BaseModel): + """Response from LLMOps search, including system prompt injection.""" + + model_config = ConfigDict(populate_by_name=True) + + results: List[MemoryMatch] = Field(default_factory=list, alias="results") + metadata: Dict[str, str] = Field(default_factory=dict, alias="metadata") + system_prompt_injection: str = Field("", alias="systemPromptInjection") + + +# ── LLMOps memory item CRUD models ──────────────────────────────────── + + +class MemoryItemUpdateRequest(BaseModel): + """Request payload for updating a memory item's status via LLMOps.""" + + model_config = ConfigDict(populate_by_name=True) + + status: FeedbackMemoryStatus = Field(..., alias="status") + + +class MemoryItemResponse(BaseModel): + """Response for a memory item from LLMOps.""" + + model_config = ConfigDict(populate_by_name=True) + + memory_item_id: str = Field(..., alias="memoryItemId") + memory_space_id: str = Field(..., alias="memorySpaceId") + feedback_id: Optional[str] = Field(None, alias="feedbackId") + status: Optional[FeedbackMemoryStatus] = Field(None, alias="status") + memory_space_name: Optional[str] = Field(None, alias="memorySpaceName") + user_id: Optional[str] = Field(None, alias="userId") + update_time: Optional[str] = Field(None, alias="updateTime") diff --git a/packages/uipath-platform/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py new file mode 100644 index 000000000..93494ac2e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -0,0 +1,284 @@ +"""E2E tests for MemoryService against real ECS + LLMOps endpoints. + +Prerequisites: + uipath auth --alpha # sets UIPATH_URL + UIPATH_ACCESS_TOKEN + export UIPATH_FOLDER_KEY=... # folder GUID with agent memory enabled + +Run: + cd packages/uipath-platform + uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v +""" + +import os +import uuid + +import httpx +import pytest + +from uipath.platform import UiPath +from uipath.platform.errors import EnrichedException +from uipath.platform.memory import ( + EpisodicMemoryIndex, + EpisodicMemoryListResponse, + MemoryIngestResponse, + MemorySearchRequest, + MemorySearchResponse, + SearchField, + SearchMode, + SearchSettings, +) + +pytestmark = pytest.mark.e2e + + +def _require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + pytest.skip(f"Environment variable {name} is not set") + return value + + +@pytest.fixture(scope="module") +def sdk() -> UiPath: + """Create a real UiPath client from env vars. + + Supports two auth modes: + - Token-based: UIPATH_URL + UIPATH_ACCESS_TOKEN (from `uipath auth`) + - Client credentials: UIPATH_URL + UIPATH_CLIENT_ID + UIPATH_CLIENT_SECRET (CI) + """ + _require_env("UIPATH_URL") + client_id = os.environ.get("UIPATH_CLIENT_ID") + client_secret = os.environ.get("UIPATH_CLIENT_SECRET") + if client_id and client_secret: + return UiPath(client_id=client_id, client_secret=client_secret) + _require_env("UIPATH_ACCESS_TOKEN") + return UiPath() + + +@pytest.fixture(scope="module") +def folder_key() -> str: + return _require_env("UIPATH_FOLDER_KEY") + + +@pytest.fixture(scope="module") +def base_url() -> str: + return _require_env("UIPATH_URL") + + +@pytest.fixture(scope="module") +def access_token() -> str: + """Get an access token for raw HTTP calls (feedback creation). + + In client credentials mode, creates a UiPath instance to exchange + credentials for a token. + """ + token = os.environ.get("UIPATH_ACCESS_TOKEN") + if token: + return token + client_id = os.environ.get("UIPATH_CLIENT_ID") + client_secret = os.environ.get("UIPATH_CLIENT_SECRET") + if client_id and client_secret: + client = UiPath(client_id=client_id, client_secret=client_secret) + return client._config.secret + pytest.skip("No access token or client credentials available") + return "" # unreachable + + +@pytest.fixture(scope="module") +def memory_index(sdk: UiPath, folder_key: str): # noqa: ANN201 + """Create a test memory index and clean it up after all tests.""" + unique_name = f"sdk-e2e-test-{uuid.uuid4().hex[:8]}" + index = sdk.memory.create( + name=unique_name, + description="Created by E2E test — safe to delete", + folder_key=folder_key, + ) + yield index + # Cleanup + try: + sdk.memory.delete_index(key=index.id, folder_key=folder_key) + except Exception: + pass + + +class TestMemoryServiceE2E: + """E2E tests for MemoryService lifecycle. + + Requires: UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY + """ + + # ── Index CRUD (ECS) ────────────────────────────────────────── + + def test_create_index(self, memory_index: EpisodicMemoryIndex) -> None: + """Verify index creation returns a well-formed EpisodicMemoryIndex.""" + assert memory_index.id, "Index ID should be set" + assert memory_index.name.startswith("sdk-e2e-test-") + assert memory_index.folder_key, "Folder key should be populated" + assert memory_index.memories_count == 0 + + def test_get_index( + self, + sdk: UiPath, + memory_index: EpisodicMemoryIndex, + folder_key: str, + ) -> None: + """Verify we can retrieve the index by key.""" + fetched = sdk.memory.get(key=memory_index.id, folder_key=folder_key) + assert fetched.id == memory_index.id + assert fetched.name == memory_index.name + + def test_list_indexes( + self, + sdk: UiPath, + memory_index: EpisodicMemoryIndex, + folder_key: str, + ) -> None: + """Verify list with OData filter returns our index.""" + result = sdk.memory.list( + filter=f"Name eq '{memory_index.name}'", + folder_key=folder_key, + ) + assert isinstance(result, EpisodicMemoryListResponse) + names = [idx.name for idx in result.value] + assert memory_index.name in names + + # ── Search (LLMOps) ────────────────────────────────────────── + + def test_search_empty_index( + self, + sdk: UiPath, + memory_index: EpisodicMemoryIndex, + folder_key: str, + ) -> None: + """Search an empty index — should return empty results and systemPromptInjection.""" + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="test query", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + result = sdk.memory.search( + memory_space_id=memory_index.id, + request=request, + folder_key=folder_key, + ) + assert isinstance(result, MemorySearchResponse) + assert isinstance(result.results, list) + assert isinstance(result.metadata, dict) + # systemPromptInjection should be a string (possibly empty for no results) + assert isinstance(result.system_prompt_injection, str) + + # ── Full ingest lifecycle (LLMOps) ──────────────────────────── + + def test_ingest_and_search( + self, + sdk: UiPath, + memory_index: EpisodicMemoryIndex, + folder_key: str, + base_url: str, + access_token: str, + ) -> None: + """Full lifecycle: create feedback → ingest → search → verify match.""" + # Step 1: Create a synthetic feedback via LLMOps API directly + # (MemoryService doesn't have a feedback API — this is test scaffolding) + trace_id = str(uuid.uuid4()) + span_id = str(uuid.uuid4()) + user_id = str(uuid.uuid4()) + + feedback_payload = { + "traceId": trace_id, + "spanId": span_id, + "userId": user_id, + "isPositive": True, + "isOutput": False, + "isAgentError": False, + "isAgentPlanExecution": False, + "memorySpaceId": memory_index.id, + "memorySpaceName": memory_index.name, + "attributes": '{"input": "What is the capital of France?", "output": "Paris"}', + } + + with httpx.Client( + base_url=base_url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30.0, + ) as client: + resp = client.post( + "/llmopstenant_/api/Agent/feedback", + json=feedback_payload, + ) + # If feedback creation fails (e.g. LLMOps not available), + # skip gracefully rather than fail the whole suite + if resp.status_code >= 400: + pytest.skip( + f"Could not create feedback (HTTP {resp.status_code}): {resp.text}" + ) + feedback_data = resp.json() + + feedback_id = feedback_data.get("id") or feedback_data.get("feedbackId") + assert feedback_id, f"No feedback ID in response: {feedback_data}" + + # Step 2: Ingest via MemoryService (LLMOps) + ingest_result = sdk.memory.ingest( + memory_space_id=memory_index.id, + feedback_id=feedback_id, + memory_space_name=memory_index.name, + folder_key=folder_key, + ) + assert isinstance(ingest_result, MemoryIngestResponse) + assert ingest_result.memory_item_id, "Should return a memory item ID" + + # Step 3: Search to find the ingested memory + search_request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="What is the capital of France?", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + search_result = sdk.memory.search( + memory_space_id=memory_index.id, + request=search_request, + folder_key=folder_key, + ) + assert isinstance(search_result, MemorySearchResponse) + assert isinstance(search_result.system_prompt_injection, str) + # Ingestion may be async — we verify the response shape is valid + # even if results aren't immediately available + assert isinstance(search_result.results, list) + + # ── Delete lifecycle ────────────────────────────────────────── + + def test_delete_index( + self, + sdk: UiPath, + folder_key: str, + ) -> None: + """Verify index deletion works (uses a separate index to not break other tests).""" + temp_name = f"sdk-e2e-delete-{uuid.uuid4().hex[:8]}" + temp_index = sdk.memory.create( + name=temp_name, + description="Temp index for delete test", + folder_key=folder_key, + ) + # Delete it + sdk.memory.delete_index(key=temp_index.id, folder_key=folder_key) + + # Verify it's gone — GET should raise + with pytest.raises(EnrichedException): + sdk.memory.get(key=temp_index.id, folder_key=folder_key) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1542a2fb2..67e119a75 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.13" +version = "0.1.14" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6c70be260..6f756b659 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.13" +version = "0.1.14" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },