Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
enriches traces/feedback before forwarding to ECS.
"""

import hashlib
import json
import logging
from typing import Any, Optional

from uipath.core.tracing import traced
Expand All @@ -29,6 +32,8 @@
_MEMORY_SPACES_BASE = "/ecs_/v2/episodicmemories"
_LLMOPS_AGENT_BASE = "/llmopstenant_/api/Agent/memory"

logger = logging.getLogger(__name__)


class MemoryService(FolderContext, BaseService):
"""Service for Agent Memory Spaces.
Expand Down Expand Up @@ -276,10 +281,18 @@ def escalation_search(
EscalationMemorySearchResponse: Matched escalation outcomes.
"""
spec = self._escalation_search_spec(memory_space_id, folder_key, folder_path)
request_body = request.model_dump(by_alias=True, exclude_none=True)
_log_escalation_search_request(
memory_space_id=memory_space_id,
folder_key=folder_key,
folder_path=folder_path,
headers=spec.headers,
request_body=request_body,
)
response = self.request(
spec.method,
spec.endpoint,
json=request.model_dump(by_alias=True, exclude_none=True),
json=request_body,
headers=spec.headers,
).json()
return EscalationMemorySearchResponse.model_validate(response)
Expand Down Expand Up @@ -307,11 +320,19 @@ async def escalation_search_async(
EscalationMemorySearchResponse: Matched escalation outcomes.
"""
spec = self._escalation_search_spec(memory_space_id, folder_key, folder_path)
request_body = request.model_dump(by_alias=True, exclude_none=True)
_log_escalation_search_request(
memory_space_id=memory_space_id,
folder_key=folder_key,
folder_path=folder_path,
headers=spec.headers,
request_body=request_body,
)
response = (
await self.request_async(
spec.method,
spec.endpoint,
json=request.model_dump(by_alias=True, exclude_none=True),
json=request_body,
headers=spec.headers,
)
).json()
Expand Down Expand Up @@ -491,3 +512,72 @@ def _escalation_ingest_spec(
),
headers={**header_folder(folder_key, None)},
)


def _log_escalation_search_request(
*,
memory_space_id: str,
folder_key: Optional[str],
folder_path: Optional[str],
headers: dict[str, str],
request_body: dict[str, Any],
) -> None:
logger.info(
"Escalation memory search request parameters: %s",
json.dumps(
_build_escalation_search_log_payload(
memory_space_id=memory_space_id,
folder_key=folder_key,
folder_path=folder_path,
headers=headers,
request_body=request_body,
),
default=str,
sort_keys=True,
),
)


def _build_escalation_search_log_payload(
*,
memory_space_id: str,
folder_key: Optional[str],
folder_path: Optional[str],
headers: dict[str, str],
request_body: dict[str, Any],
) -> dict[str, Any]:
fields = request_body.get("fields") or []
definition_system_prompt = request_body.get("definitionSystemPrompt")

return {
"memorySpaceId": memory_space_id,
"folderKey": folder_key,
"folderPath": folder_path,
"resolvedHeaders": headers,
"request": {
"definitionSystemPrompt": _safe_value_summary(definition_system_prompt),
"fieldCount": len(fields),
"fields": [_safe_search_field(field) for field in fields],
"settings": request_body.get("settings"),
},
}


def _safe_search_field(field: Any) -> dict[str, Any]:
if not isinstance(field, dict):
return {"field": str(type(field)), "value": _safe_value_summary(field)}

safe_field = {key: value for key, value in field.items() if key != "value"}
safe_field["value"] = _safe_value_summary(field.get("value"))
return safe_field


def _safe_value_summary(value: Any) -> dict[str, Any] | None:
if value is None:
return None

string_value = str(value)
return {
"length": len(string_value),
"sha256": hashlib.sha256(string_value.encode("utf-8")).hexdigest(),
}
125 changes: 125 additions & 0 deletions packages/uipath-platform/tests/services/test_memory_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Unit tests for MemoryService with HTTP mocking."""

import hashlib
import json
import logging

import pytest
from pytest_httpx import HTTPXMock
Expand Down Expand Up @@ -330,6 +332,129 @@ def test_search_sends_folder_header(
assert sent.headers.get("x-uipath-folderkey") == "custom-folder"

class TestEscalationSearch:
def test_escalation_search_logs_request_parameters(
self,
httpx_mock: HTTPXMock,
service: MemoryService,
base_url: str,
org: str,
tenant: str,
caplog,
) -> None:
memory_space_id = "aaaa-bbbb-cccc-dddd"
field_value = "approval request with customer details"
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=["escalation-input", "request_details"],
value=field_value,
)
],
settings=SearchSettings(
threshold=0.25,
result_count=1,
search_mode=SearchMode.Semantic,
),
)

with caplog.at_level(
logging.INFO,
logger="uipath.platform.memory._memory_service",
):
service.escalation_search(
memory_space_id=memory_space_id,
request=request,
folder_key="custom-folder",
)

log_record = next(
record
for record in caplog.records
if record.message.startswith(
"Escalation memory search request parameters: "
)
)
payload = json.loads(log_record.message.split(": ", 1)[1])

assert payload["memorySpaceId"] == memory_space_id
assert payload["folderKey"] == "custom-folder"
assert payload["resolvedHeaders"] == {"x-uipath-folderkey": "custom-folder"}
assert payload["request"]["settings"] == {
"threshold": 0.25,
"resultCount": 1,
"searchMode": "Semantic",
}
assert payload["request"]["fieldCount"] == 1
assert payload["request"]["fields"][0]["keyPath"] == [
"escalation-input",
"request_details",
]
assert payload["request"]["fields"][0]["value"] == {
"length": len(field_value),
"sha256": hashlib.sha256(field_value.encode("utf-8")).hexdigest(),
}
assert field_value not in log_record.message

async def test_escalation_search_async_logs_request_parameters(
self,
httpx_mock: HTTPXMock,
service: MemoryService,
base_url: str,
org: str,
tenant: str,
caplog,
) -> None:
memory_space_id = "aaaa-bbbb-cccc-dddd"
field_value = "async approval request"
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=["escalation-input"], value=field_value)],
settings=SearchSettings(
threshold=0.0,
result_count=1,
search_mode=SearchMode.Hybrid,
),
)

with caplog.at_level(
logging.INFO,
logger="uipath.platform.memory._memory_service",
):
await service.escalation_search_async(
memory_space_id=memory_space_id,
request=request,
)

log_record = next(
record
for record in caplog.records
if record.message.startswith(
"Escalation memory search request parameters: "
)
)
payload = json.loads(log_record.message.split(": ", 1)[1])

assert payload["memorySpaceId"] == memory_space_id
assert payload["resolvedHeaders"] == {
"x-uipath-folderkey": "test-folder-key"
}
assert payload["request"]["fields"][0]["value"] == {
"length": len(field_value),
"sha256": hashlib.sha256(field_value.encode("utf-8")).hexdigest(),
}
assert field_value not in log_record.message

def test_escalation_search(
self,
httpx_mock: HTTPXMock,
Expand Down
Loading