From 91653a05a5d2c7328a87bbd07a7d56156c1d8851 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 19 Mar 2026 17:35:33 -0700 Subject: [PATCH 01/17] feat: scaffold MemoryService for Agent Episodic Memory (URT migration) Add MemoryService client backed by ECS (/ecs_/memory/...) endpoints for Agent Episodic Memory. This enables dynamic few-shot retrieval where agents query past episodes at execution start and inject them as examples into the system prompt. New files: - memory.py: Pydantic models (MemoryField, MemoryItem, MemoryQueryRequest, etc.) - _memory_service.py: MemoryService with create, ingest, query, retrieve, delete, list - __init__.py: Module exports Also registers sdk.memory on the UiPath class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/_uipath.py | 5 + .../src/uipath/platform/memory/__init__.py | 25 + .../uipath/platform/memory/_memory_service.py | 428 ++++++++++++++++++ .../src/uipath/platform/memory/memory.py | 89 ++++ 4 files changed, 547 insertions(+) create mode 100644 packages/uipath-platform/src/uipath/platform/memory/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/memory/_memory_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/memory/memory.py diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 87c3a17f0..eb7fe1d90 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -18,6 +18,7 @@ from .common.auth import resolve_config_from_env from .connections import ConnectionsService from .context_grounding import ContextGroundingService +from .memory import MemoryService from .documents import DocumentsService from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError @@ -113,6 +114,10 @@ def context_grounding(self) -> ContextGroundingService: self.buckets, ) + @property + def memory(self) -> MemoryService: + return MemoryService(self._config, self._execution_context) + @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..70f6f3b1d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -0,0 +1,25 @@ +"""Init file for memory module.""" + +from ._memory_service import MemoryService +from .memory import ( + MemoryField, + MemoryIngestRequest, + MemoryItem, + MemoryListResponse, + MemoryQueryRequest, + MemoryQueryResponse, + MemoryQueryResult, + MemoryResource, +) + +__all__ = [ + "MemoryField", + "MemoryIngestRequest", + "MemoryItem", + "MemoryListResponse", + "MemoryQueryRequest", + "MemoryQueryResponse", + "MemoryQueryResult", + "MemoryResource", + "MemoryService", +] 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..6cd06b4f4 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -0,0 +1,428 @@ +"""Memory service for Agent Episodic Memory backed by ECS.""" + +from typing import 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 .memory import ( + MemoryIngestRequest, + MemoryItem, + MemoryListResponse, + MemoryQueryRequest, + MemoryQueryResponse, + MemoryResource, +) + + +class MemoryService(FolderContext, BaseService): + """Service for Agent Episodic Memory backed by the ECS service. + + Agent Memory allows agents to persist context across jobs using dynamic + few-shot retrieval. Memory resources are folder-scoped (like CG indexes) + and use Index.Create/Read/Update/Delete permissions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + # ── Public methods ──────────────────────────────────────────────── + + @traced(name="memory_create", run_type="uipath") + def create( + self, + name: str, + description: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> MemoryResource: + """Create a new memory resource. + + Args: + name: The name of the memory resource. + description: Optional description. + folder_key: The folder key for the operation. + + Returns: + MemoryResource: The created memory resource. + """ + spec = self._create_spec(name, description, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + return MemoryResource.model_validate(response) + + @traced(name="memory_create", run_type="uipath") + async def create_async( + self, + name: str, + description: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> MemoryResource: + """Asynchronously create a new memory resource. + + Args: + name: The name of the memory resource. + description: Optional description. + folder_key: The folder key for the operation. + + Returns: + MemoryResource: The created memory resource. + """ + spec = self._create_spec(name, description, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + ).json() + return MemoryResource.model_validate(response) + + @traced(name="memory_ingest", run_type="uipath") + def ingest( + self, + name: str, + request: MemoryIngestRequest, + folder_key: Optional[str] = None, + ) -> None: + """Ingest a memory item into the specified memory resource. + + Args: + name: The name of the memory resource. + request: The ingest request payload. + folder_key: The folder key for the operation. + """ + spec = self._ingest_spec(name, folder_key) + self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + @traced(name="memory_ingest", run_type="uipath") + async def ingest_async( + self, + name: str, + request: MemoryIngestRequest, + folder_key: Optional[str] = None, + ) -> None: + """Asynchronously ingest a memory item into the specified memory resource. + + Args: + name: The name of the memory resource. + request: The ingest request payload. + folder_key: The folder key for the operation. + """ + spec = self._ingest_spec(name, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ) + + @traced(name="memory_query", run_type="uipath") + def query( + self, + name: str, + request: MemoryQueryRequest, + folder_key: Optional[str] = None, + ) -> MemoryQueryResponse: + """Perform semantic search on memory. + + Args: + name: The name of the memory resource. + request: The query request payload. + folder_key: The folder key for the operation. + + Returns: + MemoryQueryResponse: The query results. + """ + spec = self._query_spec(name, folder_key) + response = self.request( + spec.method, + spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), + headers=spec.headers, + ).json() + return MemoryQueryResponse.model_validate(response) + + @traced(name="memory_query", run_type="uipath") + async def query_async( + self, + name: str, + request: MemoryQueryRequest, + folder_key: Optional[str] = None, + ) -> MemoryQueryResponse: + """Asynchronously perform semantic search on memory. + + Args: + name: The name of the memory resource. + request: The query request payload. + folder_key: The folder key for the operation. + + Returns: + MemoryQueryResponse: The query results. + """ + spec = self._query_spec(name, 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 MemoryQueryResponse.model_validate(response) + + @traced(name="memory_retrieve", run_type="uipath") + def retrieve( + self, + name: str, + memory_id: str, + folder_key: Optional[str] = None, + ) -> MemoryItem: + """Retrieve a single memory item by ID. + + Args: + name: The name of the memory resource. + memory_id: The ID of the memory item to retrieve. + folder_key: The folder key for the operation. + + Returns: + MemoryItem: The retrieved memory item. + """ + spec = self._retrieve_spec(name, memory_id, folder_key) + response = self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ).json() + return MemoryItem.model_validate(response) + + @traced(name="memory_retrieve", run_type="uipath") + async def retrieve_async( + self, + name: str, + memory_id: str, + folder_key: Optional[str] = None, + ) -> MemoryItem: + """Asynchronously retrieve a single memory item by ID. + + Args: + name: The name of the memory resource. + memory_id: The ID of the memory item to retrieve. + folder_key: The folder key for the operation. + + Returns: + MemoryItem: The retrieved memory item. + """ + spec = self._retrieve_spec(name, memory_id, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + ).json() + return MemoryItem.model_validate(response) + + @traced(name="memory_delete", run_type="uipath") + def delete( + self, + name: str, + memory_id: str, + folder_key: Optional[str] = None, + ) -> None: + """Delete a memory item by ID. + + Args: + name: The name of the memory resource. + memory_id: The ID of the memory item to delete. + folder_key: The folder key for the operation. + """ + spec = self._delete_spec(name, memory_id, folder_key) + self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + @traced(name="memory_delete", run_type="uipath") + async def delete_async( + self, + name: str, + memory_id: str, + folder_key: Optional[str] = None, + ) -> None: + """Asynchronously delete a memory item by ID. + + Args: + name: The name of the memory resource. + memory_id: The ID of the memory item to delete. + folder_key: The folder key for the operation. + """ + spec = self._delete_spec(name, memory_id, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + @traced(name="memory_list", run_type="uipath") + def list( + self, + name: str, + folder_key: Optional[str] = None, + ) -> MemoryListResponse: + """List all memory items in a memory resource. + + Args: + name: The name of the memory resource. + folder_key: The folder key for the operation. + + Returns: + MemoryListResponse: The list of memory items. + """ + spec = self._list_spec(name, folder_key) + response = self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ).json() + return MemoryListResponse.model_validate(response) + + @traced(name="memory_list", run_type="uipath") + async def list_async( + self, + name: str, + folder_key: Optional[str] = None, + ) -> MemoryListResponse: + """Asynchronously list all memory items in a memory resource. + + Args: + name: The name of the memory resource. + folder_key: The folder key for the operation. + + Returns: + MemoryListResponse: The list of memory items. + """ + spec = self._list_spec(name, folder_key) + response = ( + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + ).json() + return MemoryListResponse.model_validate(response) + + # ── Private spec builders ───────────────────────────────────────── + + def _resolve_folder(self, folder_key: Optional[str]) -> Optional[str]: + """Resolve the folder key, falling back to the context default.""" + return folder_key or self._folder_key + + def _create_spec( + self, + name: str, + description: Optional[str], + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint("/ecs_/memory/create"), + json={ + "name": name, + "description": description, + }, + headers={ + **header_folder(folder_key, None), + }, + ) + + def _ingest_spec( + self, + name: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/memory/{name}/ingest"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _query_spec( + self, + name: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/memory/{name}/query"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _retrieve_spec( + self, + name: str, + memory_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/memory/{name}/{memory_id}"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _delete_spec( + self, + name: str, + memory_id: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"/ecs_/memory/{name}/{memory_id}"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _list_spec( + self, + name: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/memory/{name}/list"), + 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..4c9c78989 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -0,0 +1,89 @@ +"""Pydantic models for the Memory API.""" + +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class MemoryField(BaseModel): + """A field name/value pair used in memory inputs and outputs.""" + + model_config = ConfigDict(populate_by_name=True) + + field_name: str = Field(..., alias="fieldName") + field_value: str = Field(..., alias="fieldValue") + + +class MemoryItem(BaseModel): + """A single memory item containing inputs, outputs, and trace context.""" + + model_config = ConfigDict(populate_by_name=True) + + id: Optional[str] = Field(None, alias="id") + trace_id: Optional[str] = Field(None, alias="traceId") + feedback_item_id: Optional[str] = Field(None, alias="feedbackItemId") + inputs: List[MemoryField] = Field(default_factory=list, alias="inputs") + outputs: List[MemoryField] = Field(default_factory=list, alias="outputs") + abbreviated_trace: Optional[List[str]] = Field(None, alias="abbreviatedTrace") + partition_key: Optional[str] = Field(None, alias="partitionKey") + + +class MemoryQueryRequest(BaseModel): + """Request payload for semantic search on memory.""" + + model_config = ConfigDict(populate_by_name=True) + + inputs: List[MemoryField] = Field(..., alias="inputs") + outputs: Optional[List[MemoryField]] = Field(None, alias="outputs") + abbreviated_trace: Optional[List[str]] = Field(None, alias="abbreviatedTrace") + top_k: int = Field(default=5, alias="topK") + threshold: Optional[float] = Field(None, alias="threshold") + partition_key: Optional[str] = Field(None, alias="partitionKey") + + +class MemoryQueryResult(BaseModel): + """A single result from a memory query.""" + + model_config = ConfigDict(populate_by_name=True) + + memory_item: MemoryItem = Field(..., alias="memoryItem") + score: float = Field(..., alias="score") + + +class MemoryQueryResponse(BaseModel): + """Response from a memory query operation.""" + + model_config = ConfigDict(populate_by_name=True) + + results: List[MemoryQueryResult] = Field(default_factory=list, alias="results") + + +class MemoryIngestRequest(BaseModel): + """Request payload for ingesting a memory item.""" + + model_config = ConfigDict(populate_by_name=True) + + trace_id: Optional[str] = Field(None, alias="traceId") + feedback_item_id: Optional[str] = Field(None, alias="feedbackItemId") + inputs: List[MemoryField] = Field(..., alias="inputs") + outputs: List[MemoryField] = Field(default_factory=list, alias="outputs") + abbreviated_trace: Optional[List[str]] = Field(None, alias="abbreviatedTrace") + partition_key: Optional[str] = Field(None, alias="partitionKey") + + +class MemoryResource(BaseModel): + """A memory resource (folder-scoped, similar to CG indexes).""" + + model_config = ConfigDict(populate_by_name=True) + + id: Optional[str] = Field(None, alias="id") + name: str = Field(..., alias="name") + description: Optional[str] = Field(None, alias="description") + + +class MemoryListResponse(BaseModel): + """Response from listing memory items.""" + + model_config = ConfigDict(populate_by_name=True) + + value: List[MemoryItem] = Field(default_factory=list, alias="value") From ef36ee3779502f1fa7358f557f76a0eff0b946ff Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 23 Mar 2026 14:51:10 -0700 Subject: [PATCH 02/17] fix: sort MemoryService import to fix ruff I001 lint error Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-platform/src/uipath/platform/_uipath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index eb7fe1d90..998aec7e5 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -18,11 +18,11 @@ from .common.auth import resolve_config_from_env from .connections import ConnectionsService from .context_grounding import ContextGroundingService -from .memory import MemoryService from .documents import DocumentsService from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError from .guardrails import GuardrailsService +from .memory import MemoryService from .orchestrator import ( AssetsService, AttachmentsService, From 66e3af35b19209046a4f14336ca2e54ae4f86a45 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 23 Mar 2026 14:57:43 -0700 Subject: [PATCH 03/17] fix: resolve ruff I001 import sorting errors across platform package Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-platform/src/uipath/platform/_uipath.py | 1 - packages/uipath-platform/src/uipath/platform/common/auth.py | 1 - .../src/uipath/platform/common/interrupt_models.py | 1 - .../platform/context_grounding/context_grounding_payloads.py | 1 - .../src/uipath/platform/resume_triggers/_protocol.py | 1 - 5 files changed, 5 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 998aec7e5..a9ece13e1 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -2,7 +2,6 @@ from typing import Optional from pydantic import ValidationError - from uipath.platform.automation_tracker import AutomationTrackerService from .action_center import TasksService diff --git a/packages/uipath-platform/src/uipath/platform/common/auth.py b/packages/uipath-platform/src/uipath/platform/common/auth.py index 885a0ef1b..bd03b879c 100644 --- a/packages/uipath-platform/src/uipath/platform/common/auth.py +++ b/packages/uipath-platform/src/uipath/platform/common/auth.py @@ -4,7 +4,6 @@ from typing import Optional from pydantic import BaseModel - from uipath.platform.common.constants import ( ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, diff --git a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py index 100b601bd..f01d64d81 100644 --- a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py +++ b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py @@ -3,7 +3,6 @@ from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, model_validator - from uipath.platform.context_grounding.context_grounding_index import ( ContextGroundingIndex, ) diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py index a28903462..d8f5a65cc 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel - from uipath.platform.common.constants import ( CONFLUENCE_DATA_SOURCE_REQUEST, DROPBOX_DATA_SOURCE_REQUEST, diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index 60a169da9..8d163eb48 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -17,7 +17,6 @@ UiPathResumeTriggerName, UiPathResumeTriggerType, ) - from uipath.platform import UiPath from uipath.platform.action_center import Task from uipath.platform.action_center.tasks import TaskStatus From bf6da0ee1571e52e3af113aab9a6e9f3088f586d Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 12:04:27 -0700 Subject: [PATCH 04/17] fix: align MemoryService models and endpoints with ECS v2 swagger contract The original scaffold was based on a different/older API spec. This rewrites models and endpoints to match the actual ECS v2 episodicmemories contract: - Paths: /ecs_/v2/episodicmemories (not /ecs_/memory) - Resources identified by GUID key (not name) - Field model uses keyPath[] + value (not fieldName/fieldValue) - Search replaces query, with structured SearchSettings and per-field settings - Ingest now returns EpisodicMemoryIngestResponse with memory ID - Added missing operations: delete_index, patch_memory (status active/inactive) - EpisodicMemoryIndex includes all server fields (memoriesCount, folderKey, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/__init__.py | 48 +- .../uipath/platform/memory/_memory_service.py | 456 +++++++++++------- .../src/uipath/platform/memory/memory.py | 171 +++++-- 3 files changed, 436 insertions(+), 239 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py index 70f6f3b1d..8a8c73b6b 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -2,24 +2,40 @@ from ._memory_service import MemoryService from .memory import ( - MemoryField, - MemoryIngestRequest, - MemoryItem, - MemoryListResponse, - MemoryQueryRequest, - MemoryQueryResponse, - MemoryQueryResult, - MemoryResource, + EpisodicMemoryCreateRequest, + EpisodicMemoryField, + EpisodicMemoryIndex, + EpisodicMemoryIngestRequest, + EpisodicMemoryIngestResponse, + EpisodicMemoryListResponse, + EpisodicMemoryPatchRequest, + EpisodicMemorySearchRequest, + EpisodicMemorySearchResult, + EpisodicMemoryStatus, + FieldSettings, + MemoryMatch, + MemoryMatchField, + SearchField, + SearchMode, + SearchSettings, ) __all__ = [ - "MemoryField", - "MemoryIngestRequest", - "MemoryItem", - "MemoryListResponse", - "MemoryQueryRequest", - "MemoryQueryResponse", - "MemoryQueryResult", - "MemoryResource", + "EpisodicMemoryCreateRequest", + "EpisodicMemoryField", + "EpisodicMemoryIndex", + "EpisodicMemoryIngestRequest", + "EpisodicMemoryIngestResponse", + "EpisodicMemoryListResponse", + "EpisodicMemoryPatchRequest", + "EpisodicMemorySearchRequest", + "EpisodicMemorySearchResult", + "EpisodicMemoryStatus", + "FieldSettings", + "MemoryMatch", + "MemoryMatchField", "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 index 6cd06b4f4..2f8ff319e 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -1,4 +1,4 @@ -"""Memory service for Agent Episodic Memory backed by ECS.""" +"""Episodic Memory service backed by ECS v2.""" from typing import Optional @@ -10,21 +10,26 @@ from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec from .memory import ( - MemoryIngestRequest, - MemoryItem, - MemoryListResponse, - MemoryQueryRequest, - MemoryQueryResponse, - MemoryResource, + EpisodicMemoryCreateRequest, + EpisodicMemoryIndex, + EpisodicMemoryIngestRequest, + EpisodicMemoryIngestResponse, + EpisodicMemoryListResponse, + EpisodicMemoryPatchRequest, + EpisodicMemorySearchRequest, + EpisodicMemorySearchResult, + EpisodicMemoryStatus, ) +_BASE = "/ecs_/v2/episodicmemories" + class MemoryService(FolderContext, BaseService): """Service for Agent Episodic Memory backed by the ECS service. Agent Memory allows agents to persist context across jobs using dynamic - few-shot retrieval. Memory resources are folder-scoped (like CG indexes) - and use Index.Create/Read/Update/Delete permissions. + few-shot retrieval. Memory indexes are folder-scoped and use + Index.Create/Read/Update/Delete permissions. """ def __init__( @@ -34,150 +39,217 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) - # ── Public methods ──────────────────────────────────────────────── + # ── Index operations ─────────────────────────────────────────────── @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, - ) -> MemoryResource: - """Create a new memory resource. + ) -> EpisodicMemoryIndex: + """Create a new episodic memory index. Args: - name: The name of the memory resource. - description: Optional description. + 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: - MemoryResource: The created memory resource. + EpisodicMemoryIndex: The created memory index. """ - spec = self._create_spec(name, description, folder_key) + 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 MemoryResource.model_validate(response) + 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, - ) -> MemoryResource: - """Asynchronously create a new memory resource. + ) -> EpisodicMemoryListResponse: + """List episodic memory indexes with optional OData query parameters. Args: - name: The name of the memory resource. - description: Optional description. + 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: - MemoryResource: The created memory resource. + EpisodicMemoryListResponse: The list of memory indexes. """ - spec = self._create_spec(name, description, folder_key) + 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, - json=spec.json, + params=spec.params, headers=spec.headers, ) ).json() - return MemoryResource.model_validate(response) + return EpisodicMemoryListResponse.model_validate(response) - @traced(name="memory_ingest", run_type="uipath") - def ingest( + @traced(name="memory_get", run_type="uipath") + def get( self, - name: str, - request: MemoryIngestRequest, + key: str, folder_key: Optional[str] = None, - ) -> None: - """Ingest a memory item into the specified memory resource. + ) -> EpisodicMemoryIndex: + """Get a single episodic memory index by ID. Args: - name: The name of the memory resource. - request: The ingest request payload. + key: The GUID of the memory index. folder_key: The folder key for the operation. + + Returns: + EpisodicMemoryIndex: The memory index. """ - spec = self._ingest_spec(name, folder_key) - self.request( + spec = self._get_spec(key, folder_key) + response = self.request( spec.method, spec.endpoint, - json=request.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, - ) + ).json() + return EpisodicMemoryIndex.model_validate(response) - @traced(name="memory_ingest", run_type="uipath") - async def ingest_async( + @traced(name="memory_get", run_type="uipath") + async def get_async( self, - name: str, - request: MemoryIngestRequest, + 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: - """Asynchronously ingest a memory item into the specified memory resource. + """Delete an episodic memory index. Args: - name: The name of the memory resource. - request: The ingest request payload. + key: The GUID of the memory index. folder_key: The folder key for the operation. """ - spec = self._ingest_spec(name, folder_key) + 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, - json=request.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ) - @traced(name="memory_query", run_type="uipath") - def query( + # ── Memory item operations ───────────────────────────────────────── + + @traced(name="memory_ingest", run_type="uipath") + def ingest( self, - name: str, - request: MemoryQueryRequest, + key: str, + request: EpisodicMemoryIngestRequest, folder_key: Optional[str] = None, - ) -> MemoryQueryResponse: - """Perform semantic search on memory. + ) -> EpisodicMemoryIngestResponse: + """Ingest a memory item into the specified index. Args: - name: The name of the memory resource. - request: The query request payload. + key: The GUID of the memory index. + request: The ingest request payload. folder_key: The folder key for the operation. Returns: - MemoryQueryResponse: The query results. + EpisodicMemoryIngestResponse: The ID of the created memory. """ - spec = self._query_spec(name, folder_key) + spec = self._ingest_spec(key, folder_key) response = self.request( spec.method, spec.endpoint, json=request.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ).json() - return MemoryQueryResponse.model_validate(response) + return EpisodicMemoryIngestResponse.model_validate(response) - @traced(name="memory_query", run_type="uipath") - async def query_async( + @traced(name="memory_ingest", run_type="uipath") + async def ingest_async( self, - name: str, - request: MemoryQueryRequest, + key: str, + request: EpisodicMemoryIngestRequest, folder_key: Optional[str] = None, - ) -> MemoryQueryResponse: - """Asynchronously perform semantic search on memory. - - Args: - name: The name of the memory resource. - request: The query request payload. - folder_key: The folder key for the operation. - - Returns: - MemoryQueryResponse: The query results. - """ - spec = self._query_spec(name, folder_key) + ) -> EpisodicMemoryIngestResponse: + """Asynchronously ingest a memory item into the specified index.""" + spec = self._ingest_spec(key, folder_key) response = ( await self.request_async( spec.method, @@ -186,149 +258,131 @@ async def query_async( headers=spec.headers, ) ).json() - return MemoryQueryResponse.model_validate(response) + return EpisodicMemoryIngestResponse.model_validate(response) - @traced(name="memory_retrieve", run_type="uipath") - def retrieve( + @traced(name="memory_search", run_type="uipath") + def search( self, - name: str, - memory_id: str, + key: str, + request: EpisodicMemorySearchRequest, folder_key: Optional[str] = None, - ) -> MemoryItem: - """Retrieve a single memory item by ID. + ) -> EpisodicMemorySearchResult: + """Perform semantic/hybrid search on episodic memory. Args: - name: The name of the memory resource. - memory_id: The ID of the memory item to retrieve. + key: The GUID of the memory index. + request: The search request payload. folder_key: The folder key for the operation. Returns: - MemoryItem: The retrieved memory item. + EpisodicMemorySearchResult: The search results with scores. """ - spec = self._retrieve_spec(name, memory_id, folder_key) + spec = self._search_spec(key, folder_key) response = self.request( spec.method, spec.endpoint, + json=request.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ).json() - return MemoryItem.model_validate(response) + return EpisodicMemorySearchResult.model_validate(response) - @traced(name="memory_retrieve", run_type="uipath") - async def retrieve_async( + @traced(name="memory_search", run_type="uipath") + async def search_async( self, - name: str, - memory_id: str, + key: str, + request: EpisodicMemorySearchRequest, folder_key: Optional[str] = None, - ) -> MemoryItem: - """Asynchronously retrieve a single memory item by ID. - - Args: - name: The name of the memory resource. - memory_id: The ID of the memory item to retrieve. - folder_key: The folder key for the operation. - - Returns: - MemoryItem: The retrieved memory item. - """ - spec = self._retrieve_spec(name, memory_id, folder_key) + ) -> EpisodicMemorySearchResult: + """Asynchronously perform semantic/hybrid search on episodic memory.""" + spec = self._search_spec(key, 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 MemoryItem.model_validate(response) + return EpisodicMemorySearchResult.model_validate(response) - @traced(name="memory_delete", run_type="uipath") - def delete( + @traced(name="memory_patch", run_type="uipath") + def patch_memory( self, - name: str, + key: str, memory_id: str, + status: EpisodicMemoryStatus, folder_key: Optional[str] = None, ) -> None: - """Delete a memory item by ID. + """Update a memory item's status (active/inactive). Args: - name: The name of the memory resource. - memory_id: The ID of the memory item to delete. + key: The GUID of the memory index. + memory_id: The GUID of the memory item. + status: The new status. folder_key: The folder key for the operation. """ - spec = self._delete_spec(name, memory_id, folder_key) + spec = self._patch_memory_spec(key, memory_id, folder_key) + body = EpisodicMemoryPatchRequest(status=status) self.request( spec.method, spec.endpoint, + json=body.model_dump(by_alias=True), headers=spec.headers, ) - @traced(name="memory_delete", run_type="uipath") - async def delete_async( + @traced(name="memory_patch", run_type="uipath") + async def patch_memory_async( self, - name: str, + key: str, memory_id: str, + status: EpisodicMemoryStatus, folder_key: Optional[str] = None, ) -> None: - """Asynchronously delete a memory item by ID. - - Args: - name: The name of the memory resource. - memory_id: The ID of the memory item to delete. - folder_key: The folder key for the operation. - """ - spec = self._delete_spec(name, memory_id, folder_key) + """Asynchronously update a memory item's status.""" + spec = self._patch_memory_spec(key, memory_id, folder_key) + body = EpisodicMemoryPatchRequest(status=status) await self.request_async( spec.method, spec.endpoint, + json=body.model_dump(by_alias=True), headers=spec.headers, ) - @traced(name="memory_list", run_type="uipath") - def list( + @traced(name="memory_delete", run_type="uipath") + def delete_memory( self, - name: str, + key: str, + memory_id: str, folder_key: Optional[str] = None, - ) -> MemoryListResponse: - """List all memory items in a memory resource. + ) -> None: + """Delete a memory item by ID. Args: - name: The name of the memory resource. + key: The GUID of the memory index. + memory_id: The GUID of the memory item. folder_key: The folder key for the operation. - - Returns: - MemoryListResponse: The list of memory items. """ - spec = self._list_spec(name, folder_key) - response = self.request( + spec = self._delete_memory_spec(key, memory_id, folder_key) + self.request( spec.method, spec.endpoint, headers=spec.headers, - ).json() - return MemoryListResponse.model_validate(response) + ) - @traced(name="memory_list", run_type="uipath") - async def list_async( + @traced(name="memory_delete", run_type="uipath") + async def delete_memory_async( self, - name: str, + key: str, + memory_id: str, folder_key: Optional[str] = None, - ) -> MemoryListResponse: - """Asynchronously list all memory items in a memory resource. - - Args: - name: The name of the memory resource. - folder_key: The folder key for the operation. - - Returns: - MemoryListResponse: The list of memory items. - """ - spec = self._list_spec(name, folder_key) - response = ( - await self.request_async( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - ).json() - return MemoryListResponse.model_validate(response) + ) -> None: + """Asynchronously delete a memory item by ID.""" + spec = self._delete_memory_spec(key, memory_id, folder_key) + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) # ── Private spec builders ───────────────────────────────────────── @@ -340,88 +394,132 @@ 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("/ecs_/memory/create"), - json={ - "name": name, - "description": description, + endpoint=Endpoint(f"{_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 = {} + 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(_BASE), + params=params, headers={ **header_folder(folder_key, None), }, ) - def _ingest_spec( + def _get_spec( self, - name: str, + key: str, folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( - method="POST", - endpoint=Endpoint(f"/ecs_/memory/{name}/ingest"), + method="GET", + endpoint=Endpoint(f"{_BASE}/{key}"), headers={ **header_folder(folder_key, None), }, ) - def _query_spec( + def _delete_index_spec( self, - name: str, + key: str, + folder_key: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder(folder_key) + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"{_BASE}/{key}"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _ingest_spec( + self, + key: str, folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( method="POST", - endpoint=Endpoint(f"/ecs_/memory/{name}/query"), + endpoint=Endpoint(f"{_BASE}/{key}/ingest"), headers={ **header_folder(folder_key, None), }, ) - def _retrieve_spec( + def _search_spec( self, - name: str, - memory_id: str, + key: str, folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/memory/{name}/{memory_id}"), + method="POST", + endpoint=Endpoint(f"{_BASE}/{key}/search"), headers={ **header_folder(folder_key, None), }, ) - def _delete_spec( + def _patch_memory_spec( self, - name: str, + key: str, memory_id: str, folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( - method="DELETE", - endpoint=Endpoint(f"/ecs_/memory/{name}/{memory_id}"), + method="PATCH", + endpoint=Endpoint(f"{_BASE}({key})/memory({memory_id})"), headers={ **header_folder(folder_key, None), }, ) - def _list_spec( + def _delete_memory_spec( self, - name: str, + key: str, + memory_id: str, folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/memory/{name}/list"), + method="DELETE", + endpoint=Endpoint(f"{_BASE}({key})/memory({memory_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 index 4c9c78989..70611ce48 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/memory.py +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -1,89 +1,172 @@ -"""Pydantic models for the Memory API.""" +"""Pydantic models for the Episodic Memory API (ECS v2).""" -from typing import List, Optional +from enum import Enum +from typing import Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field +# ── Enums ────────────────────────────────────────────────────────────── -class MemoryField(BaseModel): - """A field name/value pair used in memory inputs and outputs.""" - model_config = ConfigDict(populate_by_name=True) +class SearchMode(str, Enum): + """Search mode for episodic memory queries.""" + + hybrid = "hybrid" + semantic = "semantic" + + +class EpisodicMemoryStatus(str, Enum): + """Status of an individual memory record.""" + + active = "active" + inactive = "inactive" + - field_name: str = Field(..., alias="fieldName") - field_value: str = Field(..., alias="fieldValue") +# ── Field models ─────────────────────────────────────────────────────── -class MemoryItem(BaseModel): - """A single memory item containing inputs, outputs, and trace context.""" +class EpisodicMemoryField(BaseModel): + """A field with a key path and value, used in ingest and search requests.""" model_config = ConfigDict(populate_by_name=True) - id: Optional[str] = Field(None, alias="id") - trace_id: Optional[str] = Field(None, alias="traceId") - feedback_item_id: Optional[str] = Field(None, alias="feedbackItemId") - inputs: List[MemoryField] = Field(default_factory=list, alias="inputs") - outputs: List[MemoryField] = Field(default_factory=list, alias="outputs") - abbreviated_trace: Optional[List[str]] = Field(None, alias="abbreviatedTrace") - partition_key: Optional[str] = Field(None, alias="partitionKey") + key_path: List[str] = Field(..., alias="keyPath", min_length=1) + value: str = Field(..., alias="value", min_length=1) -class MemoryQueryRequest(BaseModel): - """Request payload for semantic search on memory.""" +class FieldSettings(BaseModel): + """Per-field search settings (optional overrides).""" model_config = ConfigDict(populate_by_name=True) - inputs: List[MemoryField] = Field(..., alias="inputs") - outputs: Optional[List[MemoryField]] = Field(None, alias="outputs") - abbreviated_trace: Optional[List[str]] = Field(None, alias="abbreviatedTrace") - top_k: int = Field(default=5, alias="topK") - threshold: Optional[float] = Field(None, alias="threshold") - partition_key: Optional[str] = Field(None, alias="partitionKey") + 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 MemoryQueryResult(BaseModel): - """A single result from a memory query.""" +class SearchField(BaseModel): + """A field in a search request, with optional per-field settings.""" model_config = ConfigDict(populate_by_name=True) - memory_item: MemoryItem = Field(..., alias="memoryItem") - score: float = Field(..., alias="score") + key_path: List[str] = Field(..., alias="keyPath", min_length=1) + value: str = Field(..., alias="value", min_length=1) + settings: Optional[FieldSettings] = Field(None, alias="settings") + + +# ── Request models ───────────────────────────────────────────────────── -class MemoryQueryResponse(BaseModel): - """Response from a memory query operation.""" +class EpisodicMemoryCreateRequest(BaseModel): + """Request payload for creating an episodic memory index.""" model_config = ConfigDict(populate_by_name=True) - results: List[MemoryQueryResult] = Field(default_factory=list, alias="results") + 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 MemoryIngestRequest(BaseModel): +class EpisodicMemoryIngestRequest(BaseModel): """Request payload for ingesting a memory item.""" model_config = ConfigDict(populate_by_name=True) - trace_id: Optional[str] = Field(None, alias="traceId") - feedback_item_id: Optional[str] = Field(None, alias="feedbackItemId") - inputs: List[MemoryField] = Field(..., alias="inputs") - outputs: List[MemoryField] = Field(default_factory=list, alias="outputs") - abbreviated_trace: Optional[List[str]] = Field(None, alias="abbreviatedTrace") - partition_key: Optional[str] = Field(None, alias="partitionKey") + fields: List[EpisodicMemoryField] = Field( + ..., alias="fields", min_length=1, max_length=20 + ) + + +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 EpisodicMemorySearchRequest(BaseModel): + """Request payload for searching episodic memory.""" + + model_config = ConfigDict(populate_by_name=True) + + fields: List[SearchField] = Field(..., alias="fields", min_length=1, max_length=20) + settings: SearchSettings = Field(..., alias="settings") + + +class EpisodicMemoryPatchRequest(BaseModel): + """Request payload for updating a memory item's status.""" + + model_config = ConfigDict(populate_by_name=True) + + status: EpisodicMemoryStatus = Field(..., alias="status") -class MemoryResource(BaseModel): - """A memory resource (folder-scoped, similar to CG indexes).""" +# ── Response models ──────────────────────────────────────────────────── + + +class EpisodicMemoryIndex(BaseModel): + """An episodic memory index (folder-scoped).""" model_config = ConfigDict(populate_by_name=True) - id: Optional[str] = Field(None, alias="id") + 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 EpisodicMemoryIngestResponse(BaseModel): + """Response from an ingest operation, containing the new memory ID.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., alias="id") + + +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") + + +class MemoryMatch(BaseModel): + """A single matched memory from a search operation.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., alias="id") + score: float = Field(..., alias="score") + semantic_score: float = Field(..., alias="semanticScore") + weighted_score: float = Field(..., alias="weightedScore") + fields: List[MemoryMatchField] = Field(..., alias="fields") + + +class EpisodicMemorySearchResult(BaseModel): + """Response from a search operation.""" + + 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") -class MemoryListResponse(BaseModel): - """Response from listing memory items.""" +class EpisodicMemoryListResponse(BaseModel): + """OData response from listing episodic memory indexes.""" model_config = ConfigDict(populate_by_name=True) - value: List[MemoryItem] = Field(default_factory=list, alias="value") + value: List[EpisodicMemoryIndex] = Field(default_factory=list, alias="value") From dfc086acbed5f97461e35fb23d3b4cb60f330d0e Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 12:16:48 -0700 Subject: [PATCH 05/17] fix: route ingest and search through LLMOps, not directly to ECS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review feedback: ingestion should go to LLMOps, which extracts fields from traces/feedback before forwarding to ECS. Architecture: - Index CRUD (create/list/get/delete) → ECS /ecs_/v2/episodicmemories - Ingest → LLMOps /llmops_/api/Agent/memory/{id}/ingest (feedbackId-based) - Search → LLMOps /llmops_/api/Agent/memory/{id}/search (returns systemPromptInjection) - Patch/delete items → LLMOps /llmops_/api/Memory/{id}/items/{itemId} LLMOps search returns systemPromptInjection — the formatted string ready to inject into the agent's system prompt for few-shot retrieval. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/__init__.py | 22 +- .../uipath/platform/memory/_memory_service.py | 276 ++++++++++-------- .../src/uipath/platform/memory/memory.py | 160 ++++++---- 3 files changed, 272 insertions(+), 186 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py index 8a8c73b6b..d00769e74 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -5,16 +5,19 @@ EpisodicMemoryCreateRequest, EpisodicMemoryField, EpisodicMemoryIndex, - EpisodicMemoryIngestRequest, - EpisodicMemoryIngestResponse, EpisodicMemoryListResponse, EpisodicMemoryPatchRequest, - EpisodicMemorySearchRequest, - EpisodicMemorySearchResult, EpisodicMemoryStatus, + FeedbackMemoryStatus, FieldSettings, + MemoryIngestRequest, + MemoryIngestResponse, + MemoryItemResponse, + MemoryItemUpdateRequest, MemoryMatch, MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, SearchField, SearchMode, SearchSettings, @@ -24,16 +27,19 @@ "EpisodicMemoryCreateRequest", "EpisodicMemoryField", "EpisodicMemoryIndex", - "EpisodicMemoryIngestRequest", - "EpisodicMemoryIngestResponse", "EpisodicMemoryListResponse", "EpisodicMemoryPatchRequest", - "EpisodicMemorySearchRequest", - "EpisodicMemorySearchResult", "EpisodicMemoryStatus", + "FeedbackMemoryStatus", "FieldSettings", + "MemoryIngestRequest", + "MemoryIngestResponse", + "MemoryItemResponse", + "MemoryItemUpdateRequest", "MemoryMatch", "MemoryMatchField", + "MemorySearchRequest", + "MemorySearchResponse", "MemoryService", "SearchField", "SearchMode", diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index 2f8ff319e..fec368a02 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -1,4 +1,9 @@ -"""Episodic Memory service backed by ECS v2.""" +"""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 Optional @@ -12,24 +17,28 @@ from .memory import ( EpisodicMemoryCreateRequest, EpisodicMemoryIndex, - EpisodicMemoryIngestRequest, - EpisodicMemoryIngestResponse, EpisodicMemoryListResponse, - EpisodicMemoryPatchRequest, - EpisodicMemorySearchRequest, - EpisodicMemorySearchResult, - EpisodicMemoryStatus, + FeedbackMemoryStatus, + MemoryIngestRequest, + MemoryIngestResponse, + MemoryItemResponse, + MemoryItemUpdateRequest, + MemorySearchRequest, + MemorySearchResponse, ) -_BASE = "/ecs_/v2/episodicmemories" +_ECS_BASE = "/ecs_/v2/episodicmemories" +_LLMOPS_AGENT_BASE = "/llmops_/api/Agent/memory" +_LLMOPS_MEMORY_BASE = "/llmops_/api/Memory" class MemoryService(FolderContext, BaseService): - """Service for Agent Episodic Memory backed by the ECS service. + """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 use - Index.Create/Read/Update/Delete permissions. + 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__( @@ -39,7 +48,7 @@ def __init__( ) -> None: super().__init__(config=config, execution_context=execution_context) - # ── Index operations ─────────────────────────────────────────────── + # ── Index operations (ECS) ───────────────────────────────────────── @traced(name="memory_create", run_type="uipath") def create( @@ -213,88 +222,106 @@ async def delete_index_async( headers=spec.headers, ) - # ── Memory item operations ───────────────────────────────────────── + # ── Ingest (LLMOps) ─────────────────────────────────────────────── @traced(name="memory_ingest", run_type="uipath") def ingest( self, - key: str, - request: EpisodicMemoryIngestRequest, + memory_space_id: str, + feedback_id: str, + memory_space_name: Optional[str] = None, + attributes: Optional[str] = None, folder_key: Optional[str] = None, - ) -> EpisodicMemoryIngestResponse: - """Ingest a memory item into the specified index. + ) -> MemoryIngestResponse: + """Ingest a memory item via LLMOps. + + LLMOps extracts fields from the trace/feedback and forwards + the ingestion to ECS. Args: - key: The GUID of the memory index. - request: The ingest request payload. + 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: - EpisodicMemoryIngestResponse: The ID of the created memory. + MemoryIngestResponse: The ID of the created memory item. """ - spec = self._ingest_spec(key, folder_key) + 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, - json=request.model_dump(by_alias=True, exclude_none=True), + params=spec.params, + json=body.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ).json() - return EpisodicMemoryIngestResponse.model_validate(response) + return MemoryIngestResponse.model_validate(response) @traced(name="memory_ingest", run_type="uipath") async def ingest_async( self, - key: str, - request: EpisodicMemoryIngestRequest, + memory_space_id: str, + feedback_id: str, + memory_space_name: Optional[str] = None, + attributes: Optional[str] = None, folder_key: Optional[str] = None, - ) -> EpisodicMemoryIngestResponse: - """Asynchronously ingest a memory item into the specified index.""" - spec = self._ingest_spec(key, folder_key) + ) -> 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, - json=request.model_dump(by_alias=True, exclude_none=True), + params=spec.params, + json=body.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ) ).json() - return EpisodicMemoryIngestResponse.model_validate(response) + return MemoryIngestResponse.model_validate(response) + + # ── Search (LLMOps) ─────────────────────────────────────────────── @traced(name="memory_search", run_type="uipath") def search( self, - key: str, - request: EpisodicMemorySearchRequest, + memory_space_id: str, + request: MemorySearchRequest, folder_key: Optional[str] = None, - ) -> EpisodicMemorySearchResult: - """Perform semantic/hybrid search on episodic memory. + ) -> MemorySearchResponse: + """Search episodic memory via LLMOps. + + Returns search results with scores and a systemPromptInjection + string ready for the agent loop. Args: - key: The GUID of the memory index. + 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: - EpisodicMemorySearchResult: The search results with scores. + MemorySearchResponse: Results, metadata, and system prompt injection. """ - spec = self._search_spec(key, folder_key) + 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 EpisodicMemorySearchResult.model_validate(response) + return MemorySearchResponse.model_validate(response) @traced(name="memory_search", run_type="uipath") async def search_async( self, - key: str, - request: EpisodicMemorySearchRequest, + memory_space_id: str, + request: MemorySearchRequest, folder_key: Optional[str] = None, - ) -> EpisodicMemorySearchResult: - """Asynchronously perform semantic/hybrid search on episodic memory.""" - spec = self._search_spec(key, folder_key) + ) -> MemorySearchResponse: + """Asynchronously search episodic memory via LLMOps.""" + spec = self._search_spec(memory_space_id, folder_key) response = ( await self.request_async( spec.method, @@ -303,66 +330,75 @@ async def search_async( headers=spec.headers, ) ).json() - return EpisodicMemorySearchResult.model_validate(response) + return MemorySearchResponse.model_validate(response) + + # ── Memory item operations (LLMOps) ─────────────────────────────── @traced(name="memory_patch", run_type="uipath") def patch_memory( self, - key: str, - memory_id: str, - status: EpisodicMemoryStatus, + memory_space_id: str, + memory_item_id: str, + status: FeedbackMemoryStatus, folder_key: Optional[str] = None, - ) -> None: - """Update a memory item's status (active/inactive). + ) -> MemoryItemResponse: + """Update a memory item's status (Enabled/Disabled) via LLMOps. Args: - key: The GUID of the memory index. - memory_id: The GUID of the memory item. + 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(key, memory_id, folder_key) - body = EpisodicMemoryPatchRequest(status=status) - self.request( + 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, - key: str, - memory_id: str, - status: EpisodicMemoryStatus, + memory_space_id: str, + memory_item_id: str, + status: FeedbackMemoryStatus, folder_key: Optional[str] = None, - ) -> None: - """Asynchronously update a memory item's status.""" - spec = self._patch_memory_spec(key, memory_id, folder_key) - body = EpisodicMemoryPatchRequest(status=status) - await self.request_async( - spec.method, - spec.endpoint, - json=body.model_dump(by_alias=True), - headers=spec.headers, - ) + ) -> 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, - key: str, - memory_id: str, + memory_space_id: str, + memory_item_id: str, folder_key: Optional[str] = None, ) -> None: - """Delete a memory item by ID. + """Delete a memory item by ID via LLMOps. Args: - key: The GUID of the memory index. - memory_id: The GUID of the memory item. + 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(key, memory_id, folder_key) + spec = self._delete_memory_spec(memory_space_id, memory_item_id, folder_key) self.request( spec.method, spec.endpoint, @@ -372,12 +408,12 @@ def delete_memory( @traced(name="memory_delete", run_type="uipath") async def delete_memory_async( self, - key: str, - memory_id: str, + memory_space_id: str, + memory_item_id: str, folder_key: Optional[str] = None, ) -> None: - """Asynchronously delete a memory item by ID.""" - spec = self._delete_memory_spec(key, memory_id, folder_key) + """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, @@ -387,9 +423,10 @@ async def delete_memory_async( # ── Private spec builders ───────────────────────────────────────── def _resolve_folder(self, folder_key: Optional[str]) -> Optional[str]: - """Resolve the folder key, falling back to the context default.""" return folder_key or self._folder_key + # -- ECS specs -- + def _create_spec( self, name: str, @@ -405,11 +442,9 @@ def _create_spec( ) return RequestSpec( method="POST", - endpoint=Endpoint(f"{_BASE}/create"), + endpoint=Endpoint(f"{_ECS_BASE}/create"), json=body.model_dump(by_alias=True, exclude_none=True), - headers={ - **header_folder(folder_key, None), - }, + headers={**header_folder(folder_key, None)}, ) def _list_spec( @@ -432,95 +467,86 @@ def _list_spec( params["$skip"] = skip return RequestSpec( method="GET", - endpoint=Endpoint(_BASE), + endpoint=Endpoint(_ECS_BASE), params=params, - headers={ - **header_folder(folder_key, None), - }, + headers={**header_folder(folder_key, None)}, ) - def _get_spec( - self, - key: str, - folder_key: Optional[str] = None, - ) -> RequestSpec: + 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"{_BASE}/{key}"), - headers={ - **header_folder(folder_key, None), - }, + endpoint=Endpoint(f"{_ECS_BASE}/{key}"), + headers={**header_folder(folder_key, None)}, ) def _delete_index_spec( - self, - key: str, - folder_key: Optional[str] = None, + self, key: str, folder_key: Optional[str] = None ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( method="DELETE", - endpoint=Endpoint(f"{_BASE}/{key}"), - headers={ - **header_folder(folder_key, None), - }, + endpoint=Endpoint(f"{_ECS_BASE}/{key}"), + headers={**header_folder(folder_key, None)}, ) + # -- LLMOps specs -- + def _ingest_spec( self, - key: str, + memory_space_id: str, + memory_space_name: Optional[str], folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) + params: dict = {} + if memory_space_name is not None: + params["memorySpaceName"] = memory_space_name return RequestSpec( method="POST", - endpoint=Endpoint(f"{_BASE}/{key}/ingest"), - headers={ - **header_folder(folder_key, None), - }, + endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/ingest"), + params=params, + headers={**header_folder(folder_key, None)}, ) def _search_spec( self, - key: str, + memory_space_id: str, folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) return RequestSpec( method="POST", - endpoint=Endpoint(f"{_BASE}/{key}/search"), - headers={ - **header_folder(folder_key, None), - }, + endpoint=Endpoint(f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/search"), + headers={**header_folder(folder_key, None)}, ) def _patch_memory_spec( self, - key: str, - memory_id: str, + 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"{_BASE}({key})/memory({memory_id})"), - headers={ - **header_folder(folder_key, None), - }, + endpoint=Endpoint( + f"{_LLMOPS_MEMORY_BASE}/{memory_space_id}/items/{memory_item_id}" + ), + headers={**header_folder(folder_key, None)}, ) def _delete_memory_spec( self, - key: str, - memory_id: str, + 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"{_BASE}({key})/memory({memory_id})"), - headers={ - **header_folder(folder_key, None), - }, + 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 index 70611ce48..624867ef4 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/memory.py +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -1,7 +1,11 @@ -"""Pydantic models for the Episodic Memory API (ECS v2).""" +"""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 Dict, List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -11,22 +15,29 @@ class SearchMode(str, Enum): """Search mode for episodic memory queries.""" - hybrid = "hybrid" - semantic = "semantic" + Hybrid = "Hybrid" + Semantic = "Semantic" class EpisodicMemoryStatus(str, Enum): - """Status of an individual memory record.""" + """Status of an individual memory record (ECS).""" active = "active" inactive = "inactive" -# ── Field models ─────────────────────────────────────────────────────── +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 ingest and search requests.""" + """A field with a key path and value, used in ECS ingest requests.""" model_config = ConfigDict(populate_by_name=True) @@ -54,61 +65,55 @@ class SearchField(BaseModel): settings: Optional[FieldSettings] = Field(None, alias="settings") -# ── Request models ───────────────────────────────────────────────────── - - -class EpisodicMemoryCreateRequest(BaseModel): - """Request payload for creating an episodic memory index.""" +class SearchSettings(BaseModel): + """Top-level search settings.""" 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") + 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 EpisodicMemoryIngestRequest(BaseModel): - """Request payload for ingesting a memory item.""" +class MemoryMatchField(BaseModel): + """A field within a search result, with scoring details.""" model_config = ConfigDict(populate_by_name=True) - fields: List[EpisodicMemoryField] = Field( - ..., alias="fields", min_length=1, max_length=20 - ) - - -class SearchSettings(BaseModel): - """Top-level search settings.""" + 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") - 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") +# ── ECS request models (index CRUD) ─────────────────────────────────── -class EpisodicMemorySearchRequest(BaseModel): - """Request payload for searching episodic memory.""" +class EpisodicMemoryCreateRequest(BaseModel): + """Request payload for creating an episodic memory index (ECS).""" model_config = ConfigDict(populate_by_name=True) - fields: List[SearchField] = Field(..., alias="fields", min_length=1, max_length=20) - settings: SearchSettings = Field(..., alias="settings") + 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.""" + """Request payload for updating a memory item's status (ECS).""" model_config = ConfigDict(populate_by_name=True) status: EpisodicMemoryStatus = Field(..., alias="status") -# ── Response models ──────────────────────────────────────────────────── +# ── ECS response models ─────────────────────────────────────────────── class EpisodicMemoryIndex(BaseModel): - """An episodic memory index (folder-scoped).""" + """An episodic memory index (folder-scoped, from ECS).""" model_config = ConfigDict(populate_by_name=True) @@ -122,51 +127,100 @@ class EpisodicMemoryIndex(BaseModel): is_encrypted: bool = Field(default=False, alias="isEncrypted") -class EpisodicMemoryIngestResponse(BaseModel): - """Response from an ingest operation, containing the new memory ID.""" +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) - id: str = Field(..., alias="id") + feedback_id: str = Field(..., alias="feedbackId") + attributes: Optional[str] = Field(None, alias="attributes") -class MemoryMatchField(BaseModel): - """A field within a search result, with scoring details.""" +class MemoryIngestResponse(BaseModel): + """Response from LLMOps ingest, containing the new memory item ID.""" 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") + 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.""" + """A single matched memory from a search operation (LLMOps).""" model_config = ConfigDict(populate_by_name=True) - id: str = Field(..., alias="id") + 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 EpisodicMemorySearchResult(BaseModel): - """Response from a search operation.""" +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") -class EpisodicMemoryListResponse(BaseModel): - """OData response from listing episodic memory indexes.""" +# ── 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) - value: List[EpisodicMemoryIndex] = Field(default_factory=list, alias="value") + 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") From 0180323352062ec344d1e66659d427acfc567b83 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 12:19:43 -0700 Subject: [PATCH 06/17] fix: resolve mypy type-arg errors in MemoryService Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/_memory_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index fec368a02..19b7f4dbc 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -5,7 +5,7 @@ before forwarding to ECS. """ -from typing import Optional +from typing import Any, Optional from uipath.core.tracing import traced @@ -456,7 +456,7 @@ def _list_spec( folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) - params: dict = {} + params: dict[str, Any] = {} if filter is not None: params["$filter"] = filter if orderby is not None: @@ -499,7 +499,7 @@ def _ingest_spec( folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) - params: dict = {} + params: dict[str, Any] = {} if memory_space_name is not None: params["memorySpaceName"] = memory_space_name return RequestSpec( From bc588999d3d8cc992a4039216e8c1fbd4ca831fa Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 12:48:50 -0700 Subject: [PATCH 07/17] test: add E2E tests for MemoryService against real ECS + LLMOps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full lifecycle test: create index → create feedback → ingest via LLMOps → search → verify response shape → delete index. Tests are marked @pytest.mark.e2e and excluded from default runs. Run with: uv run pytest -m e2e -v (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY env vars). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 5 +- .../tests/services/test_memory_service_e2e.py | 261 ++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 packages/uipath-platform/tests/services/test_memory_service_e2e.py diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 5bf170dce..50d82db0c 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py new file mode 100644 index 000000000..97e6fc40a --- /dev/null +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -0,0 +1,261 @@ +"""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.""" + _require_env("UIPATH_URL") + _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: + return _require_env("UIPATH_ACCESS_TOKEN") + + +@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( + "/llmops_/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) From e1adf5eb77e8eab305b7ad0ff0f1fcafbd78389c Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 13:11:02 -0700 Subject: [PATCH 08/17] fix: use llmopstenant_ gateway prefix and require field settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLMOps endpoints use llmopstenant_ prefix (not llmops_) through the platform gateway — llmops_ returns 302, llmopstenant_ routes correctly - SearchField.settings is now required (default FieldSettings()) per LLMOps API contract which requires the settings field on each field - Fixed E2E test feedback endpoint to use llmopstenant_ prefix E2E results: 5 passed, 1 skipped (ingest skipped because synthetic trace/span IDs don't exist in LLMOps trace store — needs real agent trace for full lifecycle test) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/_memory_service.py | 4 ++-- packages/uipath-platform/src/uipath/platform/memory/memory.py | 4 ++-- .../uipath-platform/tests/services/test_memory_service_e2e.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index 19b7f4dbc..1224a200a 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -28,8 +28,8 @@ ) _ECS_BASE = "/ecs_/v2/episodicmemories" -_LLMOPS_AGENT_BASE = "/llmops_/api/Agent/memory" -_LLMOPS_MEMORY_BASE = "/llmops_/api/Memory" +_LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" +_LLMOPS_MEMORY_BASE = "/llmopstenant_/api/Memory" class MemoryService(FolderContext, BaseService): diff --git a/packages/uipath-platform/src/uipath/platform/memory/memory.py b/packages/uipath-platform/src/uipath/platform/memory/memory.py index 624867ef4..3cb9f09c0 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/memory.py +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -56,13 +56,13 @@ class FieldSettings(BaseModel): class SearchField(BaseModel): - """A field in a search request, with optional per-field settings.""" + """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: Optional[FieldSettings] = Field(None, alias="settings") + settings: FieldSettings = Field(default_factory=FieldSettings, alias="settings") class SearchSettings(BaseModel): diff --git a/packages/uipath-platform/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py index 97e6fc40a..df882505f 100644 --- a/packages/uipath-platform/tests/services/test_memory_service_e2e.py +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -189,7 +189,7 @@ def test_ingest_and_search( timeout=30.0, ) as client: resp = client.post( - "/llmops_/api/Agent/feedback", + "/llmopstenant_/api/Agent/feedback", json=feedback_payload, ) # If feedback creation fails (e.g. LLMOps not available), From 166d400f248d0c302c436969a3ffa933a88c3220 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 13:32:31 -0700 Subject: [PATCH 09/17] ci: add E2E memory tests to GitHub Actions workflow - Adds e2e-uipath-platform job to test-packages.yml - Runs on uipath-platform changes, Python 3.11, ubuntu-latest - Uses ALPHA_TEST_CLIENT_ID/CLIENT_SECRET for auth (same as integration tests) - Reads memory folder from UIPATH_MEMORY_FOLDER secret - E2E results are non-blocking in the test gate (informational) - Test supports both token-based (local) and client credentials (CI) auth Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test-packages.yml | 55 ++++++++++++++++++- .../tests/services/test_memory_service_e2e.py | 27 ++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) 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/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py index df882505f..93494ac2e 100644 --- a/packages/uipath-platform/tests/services/test_memory_service_e2e.py +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -40,8 +40,17 @@ def _require_env(name: str) -> str: @pytest.fixture(scope="module") def sdk() -> UiPath: - """Create a real UiPath client from env vars.""" + """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() @@ -58,7 +67,21 @@ def base_url() -> str: @pytest.fixture(scope="module") def access_token() -> str: - return _require_env("UIPATH_ACCESS_TOKEN") + """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") From 4443fe0e92df3fdc13ecd6998488d13a689a40d1 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 13:56:49 -0700 Subject: [PATCH 10/17] fix: resolve folder key from folder_path for serverless environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: _resolve_folder now matches ContextGroundingService pattern — resolves folder_key from folder_path via FolderService when UIPATH_FOLDER_KEY is not set (serverless/robot environments only have UIPATH_FOLDER_PATH). MemoryService now takes a FolderService dependency, wired through the UiPath class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/_uipath.py | 2 +- .../uipath/platform/memory/_memory_service.py | 29 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index a9ece13e1..85b834738 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -115,7 +115,7 @@ def context_grounding(self) -> ContextGroundingService: @property def memory(self) -> MemoryService: - return MemoryService(self._config, self._execution_context) + return MemoryService(self._config, self._execution_context, self.folders) @property def documents(self) -> DocumentsService: diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index 1224a200a..69a8bcdef 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -14,6 +14,7 @@ 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, @@ -45,8 +46,10 @@ 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) ───────────────────────────────────────── @@ -422,8 +425,30 @@ async def delete_memory_async( # ── Private spec builders ───────────────────────────────────────── - def _resolve_folder(self, folder_key: Optional[str]) -> Optional[str]: - return folder_key or self._folder_key + 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 -- From d0667333cde329c5913967f9e61358b37b6ed431 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 15:32:53 -0700 Subject: [PATCH 11/17] refactor: clean up MemoryService to match actual API usage Remove methods not used by any frontend or backend flow: - get(), delete_index() (neither frontend uses; backend uses v1.1) - ingest() (IM-internal, requires feedback_id from trace pipeline) - patch_memory(), delete_memory() (wrong endpoints, IM admin ops) Remove unused models: FeedbackMemoryStatus, EpisodicMemoryPatchRequest, MemoryIngestRequest/Response, MemoryItemUpdateRequest/Response. Add escalation memory methods used by the backend agentic loop: - escalation_search() -> POST /api/Agent/memory/{id}/escalation/search - escalation_ingest() -> POST /api/Agent/memory/{id}/escalation/ingest Add models: EscalationMemoryIngestRequest, EscalationMemorySearchResponse, EscalationMemoryMatch, CachedRecall (matching backend C# contracts). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/__init__.py | 20 +- .../uipath/platform/memory/_memory_service.py | 278 ++++-------------- .../src/uipath/platform/memory/memory.py | 87 +++--- .../tests/services/test_memory_service_e2e.py | 138 +-------- 4 files changed, 105 insertions(+), 418 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py index d00769e74..4952dcf79 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -2,18 +2,16 @@ from ._memory_service import MemoryService from .memory import ( + CachedRecall, EpisodicMemoryCreateRequest, EpisodicMemoryField, EpisodicMemoryIndex, EpisodicMemoryListResponse, - EpisodicMemoryPatchRequest, EpisodicMemoryStatus, - FeedbackMemoryStatus, + EscalationMemoryIngestRequest, + EscalationMemoryMatch, + EscalationMemorySearchResponse, FieldSettings, - MemoryIngestRequest, - MemoryIngestResponse, - MemoryItemResponse, - MemoryItemUpdateRequest, MemoryMatch, MemoryMatchField, MemorySearchRequest, @@ -24,18 +22,16 @@ ) __all__ = [ + "CachedRecall", "EpisodicMemoryCreateRequest", "EpisodicMemoryField", "EpisodicMemoryIndex", "EpisodicMemoryListResponse", - "EpisodicMemoryPatchRequest", "EpisodicMemoryStatus", - "FeedbackMemoryStatus", + "EscalationMemoryIngestRequest", + "EscalationMemoryMatch", + "EscalationMemorySearchResponse", "FieldSettings", - "MemoryIngestRequest", - "MemoryIngestResponse", - "MemoryItemResponse", - "MemoryItemUpdateRequest", "MemoryMatch", "MemoryMatchField", "MemorySearchRequest", diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index 69a8bcdef..6f0f25763 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -1,8 +1,8 @@ """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. +Index management (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 @@ -19,18 +19,14 @@ EpisodicMemoryCreateRequest, EpisodicMemoryIndex, EpisodicMemoryListResponse, - FeedbackMemoryStatus, - MemoryIngestRequest, - MemoryIngestResponse, - MemoryItemResponse, - MemoryItemUpdateRequest, + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, MemorySearchRequest, MemorySearchResponse, ) _ECS_BASE = "/ecs_/v2/episodicmemories" _LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" -_LLMOPS_MEMORY_BASE = "/llmopstenant_/api/Memory" class MemoryService(FolderContext, BaseService): @@ -38,8 +34,9 @@ class MemoryService(FolderContext, BaseService): 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. + 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__( @@ -152,139 +149,6 @@ async def list_async( ).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") @@ -335,91 +199,94 @@ async def search_async( ).json() return MemorySearchResponse.model_validate(response) - # ── Memory item operations (LLMOps) ─────────────────────────────── + # ── Escalation memory (LLMOps) ──────────────────────────────────── - @traced(name="memory_patch", run_type="uipath") - def patch_memory( + @traced(name="memory_escalation_search", run_type="uipath") + def escalation_search( self, memory_space_id: str, - memory_item_id: str, - status: FeedbackMemoryStatus, + request: MemorySearchRequest, folder_key: Optional[str] = None, - ) -> MemoryItemResponse: - """Update a memory item's status (Enabled/Disabled) via LLMOps. + ) -> 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. - memory_item_id: The GUID of the memory item. - status: The new status. + memory_space_id: The GUID of the memory space (ECS index). + request: The search request payload (same as regular search). folder_key: The folder key for the operation. Returns: - MemoryItemResponse: The updated memory item. + EscalationMemorySearchResponse: Matched escalation outcomes. """ - spec = self._patch_memory_spec(memory_space_id, memory_item_id, folder_key) - body = MemoryItemUpdateRequest(status=status) + spec = self._escalation_search_spec(memory_space_id, folder_key) response = self.request( spec.method, spec.endpoint, - json=body.model_dump(by_alias=True), + json=request.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ).json() - return MemoryItemResponse.model_validate(response) + return EscalationMemorySearchResponse.model_validate(response) - @traced(name="memory_patch", run_type="uipath") - async def patch_memory_async( + @traced(name="memory_escalation_search", run_type="uipath") + async def escalation_search_async( self, memory_space_id: str, - memory_item_id: str, - status: FeedbackMemoryStatus, + request: MemorySearchRequest, 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) + ) -> EscalationMemorySearchResponse: + """Asynchronously search escalation memory.""" + spec = self._escalation_search_spec(memory_space_id, folder_key) response = ( await self.request_async( spec.method, spec.endpoint, - json=body.model_dump(by_alias=True), + json=request.model_dump(by_alias=True, exclude_none=True), headers=spec.headers, ) ).json() - return MemoryItemResponse.model_validate(response) + return EscalationMemorySearchResponse.model_validate(response) - @traced(name="memory_delete", run_type="uipath") - def delete_memory( + @traced(name="memory_escalation_ingest", run_type="uipath") + def escalation_ingest( self, memory_space_id: str, - memory_item_id: str, + request: EscalationMemoryIngestRequest, folder_key: Optional[str] = None, ) -> None: - """Delete a memory item by ID via LLMOps. + """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. - memory_item_id: The GUID of the memory item. + memory_space_id: The GUID of the memory space (ECS index). + request: The escalation ingest payload. folder_key: The folder key for the operation. """ - spec = self._delete_memory_spec(memory_space_id, memory_item_id, folder_key) + 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_delete", run_type="uipath") - async def delete_memory_async( + @traced(name="memory_escalation_ingest", run_type="uipath") + async def escalation_ingest_async( self, memory_space_id: str, - memory_item_id: str, + request: EscalationMemoryIngestRequest, 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) + """Asynchronously ingest a resolved escalation outcome.""" + 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, ) @@ -497,43 +364,8 @@ def _list_spec( 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, @@ -546,32 +378,30 @@ def _search_spec( headers={**header_folder(folder_key, None)}, ) - def _patch_memory_spec( + def _escalation_search_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", + method="POST", endpoint=Endpoint( - f"{_LLMOPS_MEMORY_BASE}/{memory_space_id}/items/{memory_item_id}" + f"{_LLMOPS_AGENT_BASE}/{memory_space_id}/escalation/search" ), headers={**header_folder(folder_key, None)}, ) - def _delete_memory_spec( + def _escalation_ingest_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", + method="POST", endpoint=Endpoint( - f"{_LLMOPS_MEMORY_BASE}/{memory_space_id}/items/{memory_item_id}" + 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 index 3cb9f09c0..a12acb6fb 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/memory.py +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -1,7 +1,8 @@ """Pydantic models for the Episodic Memory API. -Index management goes through ECS v2. Ingest and search go through LLMOps, +Index management 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 @@ -26,13 +27,6 @@ class EpisodicMemoryStatus(str, Enum): 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) ───────────────── @@ -84,7 +78,6 @@ class MemoryMatchField(BaseModel): 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") @@ -101,14 +94,6 @@ class EpisodicMemoryCreateRequest(BaseModel): 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 ─────────────────────────────────────────────── @@ -135,29 +120,6 @@ class EpisodicMemoryListResponse(BaseModel): 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 ────────────────────────────────────────────── @@ -201,26 +163,45 @@ class MemorySearchResponse(BaseModel): system_prompt_injection: str = Field("", alias="systemPromptInjection") -# ── LLMOps memory item CRUD models ──────────────────────────────────── +# ── LLMOps escalation memory models ────────────────────────────────── + +class EscalationMemoryIngestRequest(BaseModel): + """Request payload for ingesting an escalation outcome into memory. -class MemoryItemUpdateRequest(BaseModel): - """Request payload for updating a memory item's status via LLMOps.""" + 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) - status: FeedbackMemoryStatus = Field(..., alias="status") + 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 MemoryItemResponse(BaseModel): - """Response for a memory item from LLMOps.""" +class CachedRecall(BaseModel): + """A cached escalation answer retrieved from memory.""" 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") + 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_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py index 93494ac2e..da86e33a2 100644 --- a/packages/uipath-platform/tests/services/test_memory_service_e2e.py +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -12,15 +12,13 @@ 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, + EscalationMemorySearchResponse, MemorySearchRequest, MemorySearchResponse, SearchField, @@ -60,30 +58,6 @@ 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.""" @@ -94,11 +68,6 @@ def memory_index(sdk: UiPath, folder_key: str): # noqa: ANN201 folder_key=folder_key, ) yield index - # Cleanup - try: - sdk.memory.delete_index(key=index.id, folder_key=folder_key) - except Exception: - pass class TestMemoryServiceE2E: @@ -116,17 +85,6 @@ def test_create_index(self, memory_index: EpisodicMemoryIndex) -> None: 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, @@ -173,75 +131,22 @@ def test_search_empty_index( 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) ──────────────────────────── + # ── Escalation search (LLMOps) ──────────────────────────────── - def test_ingest_and_search( + def test_escalation_search_empty_index( 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( + """Search escalation memory on empty index — should return valid response.""" + request = MemorySearchRequest( fields=[ SearchField( key_path=["input"], - value="What is the capital of France?", + value="test escalation query", ) ], settings=SearchSettings( @@ -251,34 +156,9 @@ def test_ingest_and_search( ), definition_system_prompt="You are a helpful assistant.", ) - search_result = sdk.memory.search( + result = sdk.memory.escalation_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", + request=request, 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) + assert isinstance(result, EscalationMemorySearchResponse) From 0090a21159b7d28053062e101bf53d35cd6f9f3d Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 15:39:36 -0700 Subject: [PATCH 12/17] fix: address PR review comments on MemoryService - Rename _ECS_BASE to _MEMORY_SPACES_BASE for clarity - Add full docstrings (Args/Returns) to all async methods to match their sync counterparts Co-Authored-By: Claude Opus 4.6 (1M context) --- .../uipath/platform/memory/_memory_service.py | 70 ++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py index 6f0f25763..a5b54dede 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -25,7 +25,7 @@ MemorySearchResponse, ) -_ECS_BASE = "/ecs_/v2/episodicmemories" +_MEMORY_SPACES_BASE = "/ecs_/v2/episodicmemories" _LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory" @@ -86,7 +86,17 @@ async def create_async( is_encrypted: Optional[bool] = None, folder_key: Optional[str] = None, ) -> EpisodicMemoryIndex: - """Asynchronously create a new episodic memory index.""" + """Asynchronously 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 = ( await self.request_async( @@ -137,7 +147,18 @@ async def list_async( skip: Optional[int] = None, folder_key: Optional[str] = None, ) -> EpisodicMemoryListResponse: - """Asynchronously list episodic memory indexes.""" + """Asynchronously list episodic memory indexes. + + 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 = ( await self.request_async( @@ -187,7 +208,19 @@ async def search_async( request: MemorySearchRequest, folder_key: Optional[str] = None, ) -> MemorySearchResponse: - """Asynchronously search episodic memory via LLMOps.""" + """Asynchronously 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 = ( await self.request_async( @@ -237,7 +270,19 @@ async def escalation_search_async( request: MemorySearchRequest, folder_key: Optional[str] = None, ) -> EscalationMemorySearchResponse: - """Asynchronously search escalation memory.""" + """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 (ECS index). + 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( @@ -281,7 +326,16 @@ async def escalation_ingest_async( request: EscalationMemoryIngestRequest, folder_key: Optional[str] = None, ) -> None: - """Asynchronously ingest a resolved escalation outcome.""" + """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 (ECS index). + 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, @@ -334,7 +388,7 @@ def _create_spec( ) return RequestSpec( method="POST", - endpoint=Endpoint(f"{_ECS_BASE}/create"), + endpoint=Endpoint(f"{_MEMORY_SPACES_BASE}/create"), json=body.model_dump(by_alias=True, exclude_none=True), headers={**header_folder(folder_key, None)}, ) @@ -359,7 +413,7 @@ def _list_spec( params["$skip"] = skip return RequestSpec( method="GET", - endpoint=Endpoint(_ECS_BASE), + endpoint=Endpoint(_MEMORY_SPACES_BASE), params=params, headers={**header_folder(folder_key, None)}, ) From 8bdf0907318546ec8657f95eb215b660d68d9684 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 15:43:12 -0700 Subject: [PATCH 13/17] test: add unit tests for MemoryService with HTTP mocking Cover all 5 public methods: create, list, search, escalation_search, escalation_ingest. Tests verify correct URL construction, request body serialization, folder header propagation, and response deserialization. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/services/test_memory_service.py | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 packages/uipath-platform/tests/services/test_memory_service.py 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..f45ba2b89 --- /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 ( + EpisodicMemoryIndex, + EpisodicMemoryListResponse, + EscalationMemoryIngestRequest, + EscalationMemorySearchResponse, + MemoryMatch, + MemoryMatchField, + MemorySearchRequest, + MemorySearchResponse, + 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, EpisodicMemoryIndex) + 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, EpisodicMemoryListResponse) + 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, EpisodicMemoryListResponse) + + 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, EpisodicMemoryListResponse) + 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 From 5e8c8e07a39012c3a7e5563ad9f7180d0ff72f2f Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 15:45:22 -0700 Subject: [PATCH 14/17] chore: remove unused EpisodicMemoryField and EpisodicMemoryStatus EpisodicMemoryField was for ECS ingest (removed). EpisodicMemoryStatus is not referenced by any service method or model. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/__init__.py | 4 ---- .../src/uipath/platform/memory/memory.py | 16 ---------------- 2 files changed, 20 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py index 4952dcf79..3ce72acf0 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -4,10 +4,8 @@ from .memory import ( CachedRecall, EpisodicMemoryCreateRequest, - EpisodicMemoryField, EpisodicMemoryIndex, EpisodicMemoryListResponse, - EpisodicMemoryStatus, EscalationMemoryIngestRequest, EscalationMemoryMatch, EscalationMemorySearchResponse, @@ -24,10 +22,8 @@ __all__ = [ "CachedRecall", "EpisodicMemoryCreateRequest", - "EpisodicMemoryField", "EpisodicMemoryIndex", "EpisodicMemoryListResponse", - "EpisodicMemoryStatus", "EscalationMemoryIngestRequest", "EscalationMemoryMatch", "EscalationMemorySearchResponse", diff --git a/packages/uipath-platform/src/uipath/platform/memory/memory.py b/packages/uipath-platform/src/uipath/platform/memory/memory.py index a12acb6fb..c2a68a326 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/memory.py +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -20,25 +20,9 @@ class SearchMode(str, Enum): Semantic = "Semantic" -class EpisodicMemoryStatus(str, Enum): - """Status of an individual memory record (ECS).""" - - active = "active" - inactive = "inactive" - - # ── 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).""" From a0ea8142cb6f450549e0d437917dbf11ce5bcf95 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 16:07:21 -0700 Subject: [PATCH 15/17] refactor: rename EpisodicMemoryIndex to MemorySpace per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace "index" terminology with "memory space" throughout the memory module to match the product language used by frontends and backend (MemorySpaceResponse, MemorySpace, etc.). Renames: - EpisodicMemoryIndex → MemorySpace - EpisodicMemoryListResponse → MemorySpaceListResponse - EpisodicMemoryCreateRequest → MemorySpaceCreateRequest All docstrings updated to use "memory space" instead of "memory index". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/uipath/platform/memory/__init__.py | 12 +-- .../uipath/platform/memory/_memory_service.py | 74 +++++++++---------- .../src/uipath/platform/memory/memory.py | 22 +++--- .../tests/services/test_memory_service.py | 12 +-- .../tests/services/test_memory_service_e2e.py | 16 ++-- 5 files changed, 68 insertions(+), 68 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/memory/__init__.py b/packages/uipath-platform/src/uipath/platform/memory/__init__.py index 3ce72acf0..31e364814 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/memory/__init__.py @@ -3,9 +3,6 @@ from ._memory_service import MemoryService from .memory import ( CachedRecall, - EpisodicMemoryCreateRequest, - EpisodicMemoryIndex, - EpisodicMemoryListResponse, EscalationMemoryIngestRequest, EscalationMemoryMatch, EscalationMemorySearchResponse, @@ -14,6 +11,9 @@ MemoryMatchField, MemorySearchRequest, MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, SearchField, SearchMode, SearchSettings, @@ -21,9 +21,6 @@ __all__ = [ "CachedRecall", - "EpisodicMemoryCreateRequest", - "EpisodicMemoryIndex", - "EpisodicMemoryListResponse", "EscalationMemoryIngestRequest", "EscalationMemoryMatch", "EscalationMemorySearchResponse", @@ -33,6 +30,9 @@ "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 index a5b54dede..89ec86a25 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py +++ b/packages/uipath-platform/src/uipath/platform/memory/_memory_service.py @@ -1,6 +1,6 @@ -"""Episodic Memory service. +"""Memory Spaces service. -Index management (create/list) goes through ECS v2. +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. """ @@ -16,13 +16,13 @@ from ..common._models import Endpoint, RequestSpec from ..orchestrator._folder_service import FolderService from .memory import ( - EpisodicMemoryCreateRequest, - EpisodicMemoryIndex, - EpisodicMemoryListResponse, EscalationMemoryIngestRequest, EscalationMemorySearchResponse, MemorySearchRequest, MemorySearchResponse, + MemorySpace, + MemorySpaceCreateRequest, + MemorySpaceListResponse, ) _MEMORY_SPACES_BASE = "/ecs_/v2/episodicmemories" @@ -30,10 +30,10 @@ class MemoryService(FolderContext, BaseService): - """Service for Agent Episodic Memory. + """Service for Agent Memory Spaces. Agent Memory allows agents to persist context across jobs using dynamic - few-shot retrieval. Memory indexes are folder-scoped and managed via ECS. + 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. @@ -48,7 +48,7 @@ def __init__( super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - # ── Index operations (ECS) ───────────────────────────────────────── + # ── Memory space operations (ECS) ────────────────────────────────── @traced(name="memory_create", run_type="uipath") def create( @@ -57,17 +57,17 @@ def create( description: Optional[str] = None, is_encrypted: Optional[bool] = None, folder_key: Optional[str] = None, - ) -> EpisodicMemoryIndex: - """Create a new episodic memory index. + ) -> MemorySpace: + """Create a new memory space. Args: - name: The name of the memory index (max 128 chars). + name: The name of the memory space (max 128 chars). description: Optional description (max 1024 chars). - is_encrypted: Whether the index should be encrypted. + is_encrypted: Whether the memory space should be encrypted. folder_key: The folder key for the operation. Returns: - EpisodicMemoryIndex: The created memory index. + MemorySpace: The created memory space. """ spec = self._create_spec(name, description, is_encrypted, folder_key) response = self.request( @@ -76,7 +76,7 @@ def create( json=spec.json, headers=spec.headers, ).json() - return EpisodicMemoryIndex.model_validate(response) + return MemorySpace.model_validate(response) @traced(name="memory_create", run_type="uipath") async def create_async( @@ -85,17 +85,17 @@ async def create_async( description: Optional[str] = None, is_encrypted: Optional[bool] = None, folder_key: Optional[str] = None, - ) -> EpisodicMemoryIndex: - """Asynchronously create a new episodic memory index. + ) -> MemorySpace: + """Asynchronously create a new memory space. Args: - name: The name of the memory index (max 128 chars). + name: The name of the memory space (max 128 chars). description: Optional description (max 1024 chars). - is_encrypted: Whether the index should be encrypted. + is_encrypted: Whether the memory space should be encrypted. folder_key: The folder key for the operation. Returns: - EpisodicMemoryIndex: The created memory index. + MemorySpace: The created memory space. """ spec = self._create_spec(name, description, is_encrypted, folder_key) response = ( @@ -106,7 +106,7 @@ async def create_async( headers=spec.headers, ) ).json() - return EpisodicMemoryIndex.model_validate(response) + return MemorySpace.model_validate(response) @traced(name="memory_list", run_type="uipath") def list( @@ -116,8 +116,8 @@ def list( top: Optional[int] = None, skip: Optional[int] = None, folder_key: Optional[str] = None, - ) -> EpisodicMemoryListResponse: - """List episodic memory indexes with optional OData query parameters. + ) -> MemorySpaceListResponse: + """List memory spaces with optional OData query parameters. Args: filter: OData $filter expression. @@ -127,7 +127,7 @@ def list( folder_key: The folder key for the operation. Returns: - EpisodicMemoryListResponse: The list of memory indexes. + MemorySpaceListResponse: The list of memory spaces. """ spec = self._list_spec(filter, orderby, top, skip, folder_key) response = self.request( @@ -136,7 +136,7 @@ def list( params=spec.params, headers=spec.headers, ).json() - return EpisodicMemoryListResponse.model_validate(response) + return MemorySpaceListResponse.model_validate(response) @traced(name="memory_list", run_type="uipath") async def list_async( @@ -146,8 +146,8 @@ async def list_async( top: Optional[int] = None, skip: Optional[int] = None, folder_key: Optional[str] = None, - ) -> EpisodicMemoryListResponse: - """Asynchronously list episodic memory indexes. + ) -> MemorySpaceListResponse: + """Asynchronously list memory spaces. Args: filter: OData $filter expression. @@ -157,7 +157,7 @@ async def list_async( folder_key: The folder key for the operation. Returns: - EpisodicMemoryListResponse: The list of memory indexes. + MemorySpaceListResponse: The list of memory spaces. """ spec = self._list_spec(filter, orderby, top, skip, folder_key) response = ( @@ -168,7 +168,7 @@ async def list_async( headers=spec.headers, ) ).json() - return EpisodicMemoryListResponse.model_validate(response) + return MemorySpaceListResponse.model_validate(response) # ── Search (LLMOps) ─────────────────────────────────────────────── @@ -179,13 +179,13 @@ def search( request: MemorySearchRequest, folder_key: Optional[str] = None, ) -> MemorySearchResponse: - """Search episodic memory via LLMOps. + """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 (ECS index). + memory_space_id: The GUID of the memory space. request: The search request payload. folder_key: The folder key for the operation. @@ -208,13 +208,13 @@ async def search_async( request: MemorySearchRequest, folder_key: Optional[str] = None, ) -> MemorySearchResponse: - """Asynchronously search episodic memory via LLMOps. + """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 (ECS index). + memory_space_id: The GUID of the memory space. request: The search request payload. folder_key: The folder key for the operation. @@ -247,7 +247,7 @@ def escalation_search( re-escalating for similar situations. Args: - memory_space_id: The GUID of the memory space (ECS index). + 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. @@ -276,7 +276,7 @@ async def escalation_search_async( re-escalating for similar situations. Args: - memory_space_id: The GUID of the memory space (ECS index). + 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. @@ -307,7 +307,7 @@ def escalation_ingest( without re-escalating. Args: - memory_space_id: The GUID of the memory space (ECS index). + memory_space_id: The GUID of the memory space. request: The escalation ingest payload. folder_key: The folder key for the operation. """ @@ -332,7 +332,7 @@ async def escalation_ingest_async( without re-escalating. Args: - memory_space_id: The GUID of the memory space (ECS index). + memory_space_id: The GUID of the memory space. request: The escalation ingest payload. folder_key: The folder key for the operation. """ @@ -381,7 +381,7 @@ def _create_spec( folder_key: Optional[str] = None, ) -> RequestSpec: folder_key = self._resolve_folder(folder_key) - body = EpisodicMemoryCreateRequest( + body = MemorySpaceCreateRequest( name=name, description=description, is_encrypted=is_encrypted, diff --git a/packages/uipath-platform/src/uipath/platform/memory/memory.py b/packages/uipath-platform/src/uipath/platform/memory/memory.py index c2a68a326..aadffbc79 100644 --- a/packages/uipath-platform/src/uipath/platform/memory/memory.py +++ b/packages/uipath-platform/src/uipath/platform/memory/memory.py @@ -1,6 +1,6 @@ -"""Pydantic models for the Episodic Memory API. +"""Pydantic models for the Memory Spaces API. -Index management goes through ECS v2. Search goes through LLMOps, +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. """ @@ -14,7 +14,7 @@ class SearchMode(str, Enum): - """Search mode for episodic memory queries.""" + """Search mode for memory space queries.""" Hybrid = "Hybrid" Semantic = "Semantic" @@ -65,11 +65,11 @@ class MemoryMatchField(BaseModel): weighted_score: float = Field(..., alias="weightedScore") -# ── ECS request models (index CRUD) ─────────────────────────────────── +# ── ECS request models (memory space CRUD) ──────────────────────────── -class EpisodicMemoryCreateRequest(BaseModel): - """Request payload for creating an episodic memory index (ECS).""" +class MemorySpaceCreateRequest(BaseModel): + """Request payload for creating a memory space (ECS).""" model_config = ConfigDict(populate_by_name=True) @@ -81,8 +81,8 @@ class EpisodicMemoryCreateRequest(BaseModel): # ── ECS response models ─────────────────────────────────────────────── -class EpisodicMemoryIndex(BaseModel): - """An episodic memory index (folder-scoped, from ECS).""" +class MemorySpace(BaseModel): + """A memory space (folder-scoped, from ECS).""" model_config = ConfigDict(populate_by_name=True) @@ -96,12 +96,12 @@ class EpisodicMemoryIndex(BaseModel): is_encrypted: bool = Field(default=False, alias="isEncrypted") -class EpisodicMemoryListResponse(BaseModel): - """OData response from listing episodic memory indexes (ECS).""" +class MemorySpaceListResponse(BaseModel): + """OData response from listing memory spaces (ECS).""" model_config = ConfigDict(populate_by_name=True) - value: List[EpisodicMemoryIndex] = Field(default_factory=list, alias="value") + value: List[MemorySpace] = Field(default_factory=list, alias="value") # ── LLMOps search models ────────────────────────────────────────────── diff --git a/packages/uipath-platform/tests/services/test_memory_service.py b/packages/uipath-platform/tests/services/test_memory_service.py index f45ba2b89..716e3438c 100644 --- a/packages/uipath-platform/tests/services/test_memory_service.py +++ b/packages/uipath-platform/tests/services/test_memory_service.py @@ -7,14 +7,14 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.memory import ( - EpisodicMemoryIndex, - EpisodicMemoryListResponse, EscalationMemoryIngestRequest, EscalationMemorySearchResponse, MemoryMatch, MemoryMatchField, MemorySearchRequest, MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, SearchField, SearchMode, SearchSettings, @@ -120,7 +120,7 @@ def test_create_memory_space( description="A test memory space", ) - assert isinstance(result, EpisodicMemoryIndex) + assert isinstance(result, MemorySpace) assert result.id == "aaaa-bbbb-cccc-dddd" assert result.name == "test-memory-space" assert result.memories_count == 5 @@ -194,7 +194,7 @@ def test_list_memory_spaces( result = service.list() - assert isinstance(result, EpisodicMemoryListResponse) + assert isinstance(result, MemorySpaceListResponse) assert len(result.value) == 1 assert result.value[0].name == "test-memory-space" @@ -219,7 +219,7 @@ def test_list_with_odata_params( skip=5, ) - assert isinstance(result, EpisodicMemoryListResponse) + assert isinstance(result, MemorySpaceListResponse) def test_list_empty( self, @@ -237,7 +237,7 @@ def test_list_empty( result = service.list() - assert isinstance(result, EpisodicMemoryListResponse) + assert isinstance(result, MemorySpaceListResponse) assert len(result.value) == 0 class TestSearch: diff --git a/packages/uipath-platform/tests/services/test_memory_service_e2e.py b/packages/uipath-platform/tests/services/test_memory_service_e2e.py index da86e33a2..6c7866611 100644 --- a/packages/uipath-platform/tests/services/test_memory_service_e2e.py +++ b/packages/uipath-platform/tests/services/test_memory_service_e2e.py @@ -16,11 +16,11 @@ from uipath.platform import UiPath from uipath.platform.memory import ( - EpisodicMemoryIndex, - EpisodicMemoryListResponse, EscalationMemorySearchResponse, MemorySearchRequest, MemorySearchResponse, + MemorySpace, + MemorySpaceListResponse, SearchField, SearchMode, SearchSettings, @@ -78,8 +78,8 @@ class TestMemoryServiceE2E: # ── Index CRUD (ECS) ────────────────────────────────────────── - def test_create_index(self, memory_index: EpisodicMemoryIndex) -> None: - """Verify index creation returns a well-formed EpisodicMemoryIndex.""" + 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" @@ -88,7 +88,7 @@ def test_create_index(self, memory_index: EpisodicMemoryIndex) -> None: def test_list_indexes( self, sdk: UiPath, - memory_index: EpisodicMemoryIndex, + memory_index: MemorySpace, folder_key: str, ) -> None: """Verify list with OData filter returns our index.""" @@ -96,7 +96,7 @@ def test_list_indexes( filter=f"Name eq '{memory_index.name}'", folder_key=folder_key, ) - assert isinstance(result, EpisodicMemoryListResponse) + assert isinstance(result, MemorySpaceListResponse) names = [idx.name for idx in result.value] assert memory_index.name in names @@ -105,7 +105,7 @@ def test_list_indexes( def test_search_empty_index( self, sdk: UiPath, - memory_index: EpisodicMemoryIndex, + memory_index: MemorySpace, folder_key: str, ) -> None: """Search an empty index — should return empty results and systemPromptInjection.""" @@ -138,7 +138,7 @@ def test_search_empty_index( def test_escalation_search_empty_index( self, sdk: UiPath, - memory_index: EpisodicMemoryIndex, + memory_index: MemorySpace, folder_key: str, ) -> None: """Search escalation memory on empty index — should return valid response.""" From cdae57e1930235cb736a332459f2a6d4f8690077 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 16:29:46 -0700 Subject: [PATCH 16/17] fix: resolve ruff I001 import sorting errors after rebase Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-platform/src/uipath/platform/_uipath.py | 1 + packages/uipath-platform/src/uipath/platform/common/auth.py | 1 + .../src/uipath/platform/common/interrupt_models.py | 1 + .../platform/context_grounding/context_grounding_payloads.py | 1 + .../src/uipath/platform/resume_triggers/_protocol.py | 1 + 5 files changed, 5 insertions(+) diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 85b834738..4697aef33 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -2,6 +2,7 @@ from typing import Optional from pydantic import ValidationError + from uipath.platform.automation_tracker import AutomationTrackerService from .action_center import TasksService diff --git a/packages/uipath-platform/src/uipath/platform/common/auth.py b/packages/uipath-platform/src/uipath/platform/common/auth.py index bd03b879c..885a0ef1b 100644 --- a/packages/uipath-platform/src/uipath/platform/common/auth.py +++ b/packages/uipath-platform/src/uipath/platform/common/auth.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import BaseModel + from uipath.platform.common.constants import ( ENV_BASE_URL, ENV_UIPATH_ACCESS_TOKEN, diff --git a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py index f01d64d81..100b601bd 100644 --- a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py +++ b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py @@ -3,6 +3,7 @@ from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, model_validator + from uipath.platform.context_grounding.context_grounding_index import ( ContextGroundingIndex, ) diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py index d8f5a65cc..a28903462 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel + from uipath.platform.common.constants import ( CONFLUENCE_DATA_SOURCE_REQUEST, DROPBOX_DATA_SOURCE_REQUEST, diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py index 8d163eb48..60a169da9 100644 --- a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -17,6 +17,7 @@ UiPathResumeTriggerName, UiPathResumeTriggerType, ) + from uipath.platform import UiPath from uipath.platform.action_center import Task from uipath.platform.action_center.tasks import TaskStatus From 38291b4ac03e5255b0904a6159e2239041750dd6 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 16:34:20 -0700 Subject: [PATCH 17/17] chore: bump uipath-platform version to 0.1.16 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 50d82db0c..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" 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" },