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 5bf170dce..b85971ebb 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.15" +version = "0.1.16" 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..31e364814 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -0,0 +1,39 @@ +"""Init file for memory module.""" + +from ._memory_service import MemoryService +from .memory import ( + CachedRecall, + EscalationMemoryIngestRequest, + EscalationMemoryMatch, + EscalationMemorySearchResponse, + FieldSettings, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) + +__all__ = [ + "CachedRecall", + "EscalationMemoryIngestRequest", + "EscalationMemoryMatch", + "EscalationMemorySearchResponse", + "FieldSettings", + "MemoryMatch", + "MemoryMatchField", + "MemorySearchRequest", + "MemorySearchResponse", + "MemoryService", + "MemorySpace", + "MemorySpaceCreateRequest", + "MemorySpaceListResponse", + "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..89ec86a25 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -0,0 +1,461 @@ +"""Memory Spaces service. + +Memory space CRUD (create/list) goes through ECS v2. +Search and escalation memory operations 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 ( + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, +) + +_MEMORY_SPACES_BASE = "/ecs_/v2/episodicmemories" +_LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" + + +class MemoryService(FolderContext, BaseService): + """Service for Agent Memory Spaces. + + Agent Memory allows agents to persist context across jobs using dynamic + few-shot retrieval. Memory spaces are folder-scoped and managed via ECS. + Search is routed through LLMOps, which handles trace/feedback enrichment + and system prompt injection. Escalation memory enables agents to recall + previously resolved escalation outcomes. + """ + + 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 + + # ── Memory space 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, + ) -> MemorySpace: + """Create a new memory space. + + Args: + name: The name of the memory space (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the memory space should be encrypted. + folder_key: The folder key for the operation. + + Returns: + MemorySpace: The created memory space. + """ + 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 MemorySpace.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, + ) -> MemorySpace: + """Asynchronously create a new memory space. + + Args: + name: The name of the memory space (max 128 chars). + description: Optional description (max 1024 chars). + is_encrypted: Whether the memory space should be encrypted. + folder_key: The folder key for the operation. + + Returns: + MemorySpace: The created memory space. + """ + 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 MemorySpace.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, + ) -> MemorySpaceListResponse: + """List memory spaces 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: + MemorySpaceListResponse: The list of memory spaces. + """ + 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 MemorySpaceListResponse.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, + ) -> MemorySpaceListResponse: + """Asynchronously list memory spaces. + + 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: + MemorySpaceListResponse: The list of memory spaces. + """ + 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 MemorySpaceListResponse.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 a memory space 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. + 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 a memory space 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. + 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 = ( + 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) + + # ── Escalation memory (LLMOps) ──────────────────────────────────── + + @traced(name="memory_escalation_search", run_type="uipath") + def escalation_search( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> EscalationMemorySearchResponse: + """Search escalation memory for previously resolved outcomes. + + Allows agents to recall past escalation resolutions to avoid + re-escalating for similar situations. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload (same as regular search). + folder_key: The folder key for the operation. + + Returns: + EscalationMemorySearchResponse: Matched escalation outcomes. + """ + spec = self._escalation_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 EscalationMemorySearchResponse.model_validate(response) + + @traced(name="memory_escalation_search", run_type="uipath") + async def escalation_search_async( + self, + memory_space_id: str, + request: MemorySearchRequest, + folder_key: Optional[str] = None, + ) -> EscalationMemorySearchResponse: + """Asynchronously search escalation memory for previously resolved outcomes. + + Allows agents to recall past escalation resolutions to avoid + re-escalating for similar situations. + + Args: + memory_space_id: The GUID of the memory space. + request: The search request payload (same as regular search). + folder_key: The folder key for the operation. + + Returns: + EscalationMemorySearchResponse: Matched escalation outcomes. + """ + spec = self._escalation_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 EscalationMemorySearchResponse.model_validate(response) + + @traced(name="memory_escalation_ingest", run_type="uipath") + def escalation_ingest( + self, + memory_space_id: str, + request: EscalationMemoryIngestRequest, + folder_key: Optional[str] = None, + ) -> None: + """Ingest a resolved escalation outcome into memory. + + Persists the outcome so future agent runs can recall it + without re-escalating. + + Args: + memory_space_id: The GUID of the memory space. + request: The escalation ingest payload. + folder_key: The folder key for the operation. + """ + spec = self._escalation_ingest_spec(memory_space_id, folder_key) + self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + @traced(name="memory_escalation_ingest", run_type="uipath") + async def escalation_ingest_async( + self, + memory_space_id: str, + request: EscalationMemoryIngestRequest, + folder_key: Optional[str] = None, + ) -> None: + """Asynchronously ingest a resolved escalation outcome into memory. + + Persists the outcome so future agent runs can recall it + without re-escalating. + + Args: + memory_space_id: The GUID of the memory space. + request: The escalation ingest payload. + folder_key: The folder key for the operation. + """ + spec = self._escalation_ingest_spec(memory_space_id, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + 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 = MemorySpaceCreateRequest( + name=name, + description=description, + is_encrypted=is_encrypted, + ) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_MEMORY_SPACES_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(_MEMORY_SPACES_BASE), + params=params, + headers={**header_folder(folder_key, None)}, + ) + + # -- LLMOps specs -- + + 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 _escalation_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}/escalation/search" + ), + headers={**header_folder(folder_key, None)}, + ) + + def _escalation_ingest_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}/escalation/ingest" + ), + 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..aadffbc79 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -0,0 +1,191 @@ +"""Pydantic models for the Memory Spaces API. + +Memory space CRUD goes through ECS v2. Search goes through LLMOps, +which enriches traces/feedback before forwarding to ECS. +Escalation memory operations also go through LLMOps. +""" + +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 memory space queries.""" + + Hybrid = "Hybrid" + Semantic = "Semantic" + + +# ── Shared field models (used by both ECS and LLMOps) ───────────────── + + +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") + weighted_score: float = Field(..., alias="weightedScore") + + +# ── ECS request models (memory space CRUD) ──────────────────────────── + + +class MemorySpaceCreateRequest(BaseModel): + """Request payload for creating a memory space (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") + + +# ── ECS response models ─────────────────────────────────────────────── + + +class MemorySpace(BaseModel): + """A memory space (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 MemorySpaceListResponse(BaseModel): + """OData response from listing memory spaces (ECS).""" + + model_config = ConfigDict(populate_by_name=True) + + value: List[MemorySpace] = Field(default_factory=list, alias="value") + + +# ── 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 escalation memory models ────────────────────────────────── + + +class EscalationMemoryIngestRequest(BaseModel): + """Request payload for ingesting an escalation outcome into memory. + + Used by the escalation tool to persist resolved outcomes so + future runs can recall them without re-escalating. + """ + + model_config = ConfigDict(populate_by_name=True) + + span_id: str = Field(..., alias="spanId") + trace_id: str = Field(..., alias="traceId") + answer: str = Field(..., alias="answer") + attributes: str = Field(..., alias="attributes") + user_id: Optional[str] = Field(None, alias="userId") + + +class CachedRecall(BaseModel): + """A cached escalation answer retrieved from memory.""" + + model_config = ConfigDict(populate_by_name=True) + + output: Optional[Any] = Field(None, alias="output") + outcome: Optional[str] = Field(None, alias="outcome") + + +class EscalationMemoryMatch(BaseModel): + """A single match from an escalation memory search.""" + + model_config = ConfigDict(populate_by_name=True) + + answer: Optional[CachedRecall] = Field(None, alias="answer") + + +class EscalationMemorySearchResponse(BaseModel): + """Response from LLMOps escalation memory search.""" + + model_config = ConfigDict(populate_by_name=True) + + results: Optional[List[EscalationMemoryMatch]] = Field(None, alias="results") diff --git a/packages/uipath-platform/tests/services/test_memory_service.py b/packages/uipath-platform/tests/services/test_memory_service.py new file mode 100644 index 000000000..716e3438c --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service.py @@ -0,0 +1,504 @@ +"""Unit tests for MemoryService with HTTP mocking.""" + +import json + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.memory import ( + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, + SearchField, + SearchMode, + SearchSettings, +) +from uipath.platform.memory._memory_service import MemoryService +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def folder_service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> FolderService: + return FolderService(config=config, execution_context=execution_context) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folder_service: FolderService, + monkeypatch: pytest.MonkeyPatch, +) -> MemoryService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") + return MemoryService( + config=config, + execution_context=execution_context, + folders_service=folder_service, + ) + + +# ── Sample response payloads ────────────────────────────────────────── + +SAMPLE_INDEX = { + "id": "aaaa-bbbb-cccc-dddd", + "name": "test-memory-space", + "description": "A test memory space", + "lastQueried": "2026-03-30T00:00:00Z", + "memoriesCount": 5, + "folderKey": "test-folder-key", + "createdByUserId": "user-123", + "isEncrypted": False, +} + +SAMPLE_LIST_RESPONSE = {"value": [SAMPLE_INDEX]} + +SAMPLE_SEARCH_RESPONSE = { + "results": [ + { + "memoryItemId": "item-001", + "score": 0.95, + "semanticScore": 0.92, + "weightedScore": 0.93, + "fields": [ + { + "keyPath": ["input"], + "value": "What is the capital of France?", + "weight": 1.0, + "score": 0.95, + "weightedScore": 0.95, + } + ], + "span": None, + "feedback": None, + } + ], + "metadata": {"queryTime": "12ms"}, + "systemPromptInjection": "Based on past interactions: Paris is the capital.", +} + +SAMPLE_ESCALATION_SEARCH_RESPONSE = { + "results": [ + { + "answer": { + "output": {"action": "approve", "reason": "meets criteria"}, + "outcome": "approved", + } + } + ], +} + + +class TestMemoryService: + """Unit tests for MemoryService.""" + + class TestCreate: + def test_create_memory_space( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json=SAMPLE_INDEX, + ) + + result = service.create( + name="test-memory-space", + description="A test memory space", + ) + + assert isinstance(result, MemorySpace) + assert result.id == "aaaa-bbbb-cccc-dddd" + assert result.name == "test-memory-space" + assert result.memories_count == 5 + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + body = json.loads(sent.content) + assert body["name"] == "test-memory-space" + assert body["description"] == "A test memory space" + + def test_create_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json=SAMPLE_INDEX, + ) + + service.create(name="test", folder_key="custom-folder-key") + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "custom-folder-key" + + def test_create_with_encryption( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories/create", + status_code=200, + json={**SAMPLE_INDEX, "isEncrypted": True}, + ) + + result = service.create( + name="encrypted-space", + is_encrypted=True, + ) + + assert result.is_encrypted is True + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert body["isEncrypted"] is True + + class TestList: + def test_list_memory_spaces( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories", + status_code=200, + json=SAMPLE_LIST_RESPONSE, + ) + + result = service.list() + + assert isinstance(result, MemorySpaceListResponse) + assert len(result.value) == 1 + assert result.value[0].name == "test-memory-space" + + def test_list_with_odata_params( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories?%24filter=Name+eq+%27test%27&%24orderby=Name+asc&%24top=10&%24skip=5", + status_code=200, + json=SAMPLE_LIST_RESPONSE, + ) + + result = service.list( + filter="Name eq 'test'", + orderby="Name asc", + top=10, + skip=5, + ) + + assert isinstance(result, MemorySpaceListResponse) + + def test_list_empty( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/episodicmemories", + status_code=200, + json={"value": []}, + ) + + result = service.list() + + assert isinstance(result, MemorySpaceListResponse) + assert len(result.value) == 0 + + class TestSearch: + def test_search_memory( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/search", + status_code=200, + json=SAMPLE_SEARCH_RESPONSE, + ) + + 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.", + ) + + result = service.search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, MemorySearchResponse) + assert len(result.results) == 1 + assert isinstance(result.results[0], MemoryMatch) + assert result.results[0].memory_item_id == "item-001" + assert result.results[0].score == 0.95 + assert isinstance(result.results[0].fields[0], MemoryMatchField) + assert ( + result.system_prompt_injection + == "Based on past interactions: Paris is the capital." + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + body = json.loads(sent.content) + assert body["fields"][0]["keyPath"] == ["input"] + assert body["settings"]["searchMode"] == "Hybrid" + assert body["definitionSystemPrompt"] == "You are a helpful assistant." + + def test_search_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/search", + status_code=200, + json=SAMPLE_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="test")], + settings=SearchSettings( + threshold=0.0, + result_count=1, + search_mode=SearchMode.Semantic, + ), + ) + + service.search( + memory_space_id=memory_space_id, + request=request, + folder_key="custom-folder", + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "custom-folder" + + class TestEscalationSearch: + def test_escalation_search( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/search", + status_code=200, + json=SAMPLE_ESCALATION_SEARCH_RESPONSE, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="approval request")], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + ) + + result = service.escalation_search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, EscalationMemorySearchResponse) + assert result.results is not None + assert len(result.results) == 1 + assert result.results[0].answer is not None + assert result.results[0].answer.outcome == "approved" + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert "/escalation/search" in str(sent.url) + + def test_escalation_search_empty_results( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/search", + status_code=200, + json={"results": None}, + ) + + request = MemorySearchRequest( + fields=[SearchField(key_path=["input"], value="no match")], + settings=SearchSettings( + threshold=0.0, + result_count=1, + search_mode=SearchMode.Hybrid, + ), + ) + + result = service.escalation_search( + memory_space_id=memory_space_id, + request=request, + ) + + assert isinstance(result, EscalationMemorySearchResponse) + assert result.results is None + + class TestEscalationIngest: + def test_escalation_ingest( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="span-123", + trace_id="trace-456", + answer='{"action": "approve"}', + attributes='{"input": "approve this?"}', + user_id="user-789", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "POST" + assert "/escalation/ingest" in str(sent.url) + body = json.loads(sent.content) + assert body["spanId"] == "span-123" + assert body["traceId"] == "trace-456" + assert body["answer"] == '{"action": "approve"}' + assert body["attributes"] == '{"input": "approve this?"}' + assert body["userId"] == "user-789" + + def test_escalation_ingest_sends_folder_header( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="s1", + trace_id="t1", + answer="yes", + attributes="{}", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + folder_key="my-folder", + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.headers.get("x-uipath-folderkey") == "my-folder" + + def test_escalation_ingest_excludes_none_user_id( + self, + httpx_mock: HTTPXMock, + service: MemoryService, + base_url: str, + org: str, + tenant: str, + ) -> None: + memory_space_id = "aaaa-bbbb-cccc-dddd" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/llmopstenant_/api/Agent/memory/{memory_space_id}/escalation/ingest", + status_code=200, + ) + + request = EscalationMemoryIngestRequest( + span_id="s1", + trace_id="t1", + answer="yes", + attributes="{}", + ) + + service.escalation_ingest( + memory_space_id=memory_space_id, + request=request, + ) + + sent = httpx_mock.get_request() + assert sent is not None + body = json.loads(sent.content) + assert "userId" not in body 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..6c7866611 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -0,0 +1,164 @@ +"""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 pytest + +from uipath.platform import UiPath +from uipath.platform.memory import ( + EscalationMemorySearchResponse, + MemorySearchRequest, + MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, + 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 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 + + +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: MemorySpace) -> None: + """Verify index creation returns a well-formed MemorySpace.""" + 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_list_indexes( + self, + sdk: UiPath, + memory_index: MemorySpace, + 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, MemorySpaceListResponse) + 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: MemorySpace, + 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) + assert isinstance(result.system_prompt_injection, str) + + # ── Escalation search (LLMOps) ──────────────────────────────── + + def test_escalation_search_empty_index( + self, + sdk: UiPath, + memory_index: MemorySpace, + folder_key: str, + ) -> None: + """Search escalation memory on empty index — should return valid response.""" + request = MemorySearchRequest( + fields=[ + SearchField( + key_path=["input"], + value="test escalation query", + ) + ], + settings=SearchSettings( + threshold=0.0, + result_count=5, + search_mode=SearchMode.Hybrid, + ), + definition_system_prompt="You are a helpful assistant.", + ) + result = sdk.memory.escalation_search( + memory_space_id=memory_index.id, + request=request, + folder_key=folder_key, + ) + assert isinstance(result, EscalationMemorySearchResponse) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1883bda06..9065b5ada 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.15" +version = "0.1.16" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 318e1bd25..209191aee 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.15" +version = "0.1.16" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },