From 731364d6ab308083c2855de7f12d5e19b75a9563 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Thu, 9 Jul 2026 16:47:16 -0300 Subject: [PATCH 1/6] feat(agent-memory): add support for multitenancy --- src/sap_cloud_sdk/agent_memory/__init__.py | 2 + .../agent_memory/_http_transport.py | 128 +++-- src/sap_cloud_sdk/agent_memory/_models.py | 7 + src/sap_cloud_sdk/agent_memory/client.py | 207 ++++++-- src/sap_cloud_sdk/agent_memory/config.py | 10 + src/sap_cloud_sdk/agent_memory/user-guide.md | 65 ++- .../integration/agentmemory.feature | 90 ++++ tests/agent_memory/integration/conftest.py | 25 + .../integration/test_agentmemory_bdd.py | 470 ++++++++++-------- tests/agent_memory/unit/test_client.py | 251 +++++++--- tests/agent_memory/unit/test_config.py | 23 + .../agent_memory/unit/test_http_transport.py | 140 +++++- .../integration/test_destination_bdd.py | 11 +- 13 files changed, 1043 insertions(+), 386 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 98195c88..2684145d 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -24,6 +24,7 @@ AgentMemoryValidationError, ) from sap_cloud_sdk.agent_memory._models import ( + AccessStrategy, Memory, Message, MessageRole, @@ -61,6 +62,7 @@ def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryC __all__ = [ + "AccessStrategy", "AgentMemoryClient", "AgentMemoryConfig", "AgentMemoryError", diff --git a/src/sap_cloud_sdk/agent_memory/_http_transport.py b/src/sap_cloud_sdk/agent_memory/_http_transport.py index e8c80176..6d6296fc 100644 --- a/src/sap_cloud_sdk/agent_memory/_http_transport.py +++ b/src/sap_cloud_sdk/agent_memory/_http_transport.py @@ -1,8 +1,8 @@ """HTTP transport for the Agent Memory service. Handles OAuth2 ``client_credentials`` token acquisition with lazy, -expiry-aware caching. If ``token_url`` is not configured, requests are -sent unauthenticated — expected for local development environments. +expiry-aware caching per tenant subdomain. If ``token_url`` is not configured, +requests are sent unauthenticated — expected for local development environments. """ from __future__ import annotations @@ -31,10 +31,9 @@ class HttpTransport: """Internal HTTP transport for the Agent Memory service. - Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) and - attaches the ``Authorization`` header to every request automatically via - ``OAuth2Session``. In no-auth mode (no ``token_url``), a plain - ``requests.Session`` is used instead. + Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) per + tenant subdomain and attaches the ``Authorization`` header automatically. + In no-auth mode (no ``token_url``), a plain ``requests.Session`` is used. Args: config: Service configuration. @@ -42,27 +41,34 @@ class HttpTransport: def __init__(self, config: AgentMemoryConfig) -> None: self._config = config - self._oauth: Optional[OAuth2Session] = None + # Keyed by tenant_subdomain (None = provider token) + self._oauth_cache: dict[Optional[str], tuple[OAuth2Session, datetime]] = {} self._plain_session: Optional[requests.Session] = None - self._token_expires_at: Optional[datetime] = None def close(self) -> None: - """Close the underlying HTTP session(s) and release resources.""" - if self._oauth is not None: - self._oauth.close() - self._oauth = None + """Close all underlying HTTP sessions and release resources.""" + for oauth, _ in self._oauth_cache.values(): + oauth.close() + self._oauth_cache.clear() if self._plain_session is not None: self._plain_session.close() self._plain_session = None # ── Public HTTP methods ──────────────────────────────────────────────────── - def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def get( + self, + path: str, + params: Optional[dict[str, Any]] = None, + *, + tenant_subdomain: Optional[str] = None, + ) -> dict[str, Any]: """Perform a GET request. Args: path: API path (appended to ``base_url``). params: Optional query parameters. + tenant_subdomain: Subscriber tenant subdomain for token derivation. Returns: Parsed JSON response body. @@ -71,14 +77,21 @@ def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, A AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("GET", path, params=params) - - def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + return self._request("GET", path, params=params, tenant_subdomain=tenant_subdomain) + + def post( + self, + path: str, + json: Optional[dict[str, Any]] = None, + *, + tenant_subdomain: Optional[str] = None, + ) -> dict[str, Any]: """Perform a POST request. Args: path: API path (appended to ``base_url``). json: Optional request body dict (serialised to JSON). + tenant_subdomain: Subscriber tenant subdomain for token derivation. Returns: Parsed JSON response body. Returns an empty dict for 204 responses. @@ -87,14 +100,21 @@ def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, An AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("POST", path, json=json) - - def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + return self._request("POST", path, json=json, tenant_subdomain=tenant_subdomain) + + def patch( + self, + path: str, + json: Optional[dict[str, Any]] = None, + *, + tenant_subdomain: Optional[str] = None, + ) -> dict[str, Any]: """Perform a PATCH request. Args: path: API path (appended to ``base_url``). json: Optional request body dict (serialised to JSON). + tenant_subdomain: Subscriber tenant subdomain for token derivation. Returns: Parsed JSON response body. Returns an empty dict for 204 responses. @@ -103,46 +123,49 @@ def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, A AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("PATCH", path, json=json) + return self._request("PATCH", path, json=json, tenant_subdomain=tenant_subdomain) - def delete(self, path: str) -> None: + def delete(self, path: str, *, tenant_subdomain: Optional[str] = None) -> None: """Perform a DELETE request. Args: path: API path (appended to ``base_url``). + tenant_subdomain: Subscriber tenant subdomain for token derivation. Raises: AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - self._request("DELETE", path) + self._request("DELETE", path, tenant_subdomain=tenant_subdomain) # ── Internal helpers ─────────────────────────────────────────────────────── - def _get_session(self) -> requests.Session: - """Return a session ready to make requests. + def _get_session(self, tenant_subdomain: Optional[str]) -> requests.Session: + """Return a session ready to make requests for the given tenant. In no-auth mode, returns a plain ``requests.Session`` (created once). In OAuth2 mode, returns an ``OAuth2Session`` with a valid token, - fetching or refreshing the token if needed. + fetching or refreshing per-tenant as needed. """ if not self._config.token_url: if self._plain_session is None: self._plain_session = requests.Session() return self._plain_session - if ( - self._oauth is not None - and self._token_expires_at is not None - and datetime.now() < self._token_expires_at - ): - return self._oauth + cached = self._oauth_cache.get(tenant_subdomain) + if cached is not None: + oauth, expires_at = cached + if datetime.now() < expires_at: + return oauth + + return self._fetch_token(tenant_subdomain) - self._oauth = self._fetch_token() - return self._oauth + def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: + """Acquire a new OAuth2 ``client_credentials`` token for the given tenant. - def _fetch_token(self) -> OAuth2Session: - """Acquire a new OAuth2 ``client_credentials`` token. + When ``tenant_subdomain`` is provided and ``config.identityzone`` is set, + derives the subscriber token URL by replacing the provider identityzone + in ``token_url`` with ``tenant_subdomain``. Returns: An ``OAuth2Session`` with a valid token attached. @@ -150,11 +173,19 @@ def _fetch_token(self) -> OAuth2Session: Raises: AgentMemoryHttpError: If the token endpoint returns an error or is unreachable. """ + token_url = self._config.token_url + if ( + tenant_subdomain is not None + and self._config.identityzone is not None + and token_url is not None + ): + token_url = token_url.replace(self._config.identityzone, tenant_subdomain) + try: client = BackendApplicationClient(client_id=self._config.client_id) oauth = OAuth2Session(client=client) token = oauth.fetch_token( - token_url=self._config.token_url, + token_url=token_url, client_id=self._config.client_id, client_secret=self._config.client_secret, timeout=self._config.timeout, @@ -163,21 +194,32 @@ def _fetch_token(self) -> OAuth2Session: raise AgentMemoryHttpError(f"Failed to obtain OAuth2 token: {exc}") from exc expires_in: int = token.get("expires_in", 3600) - self._token_expires_at = datetime.now() + timedelta( + expires_at = datetime.now() + timedelta( seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS ) - if self._oauth is not None: - self._oauth.close() + existing = self._oauth_cache.get(tenant_subdomain) + if existing is not None: + existing[0].close() + + self._oauth_cache[tenant_subdomain] = (oauth, expires_at) logger.debug( - "Obtained new Agent Memory OAuth2 token (expires in %ds)", expires_in + "Obtained new Agent Memory OAuth2 token for tenant=%r (expires in %ds)", + tenant_subdomain, + expires_in, ) return oauth - def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + def _request( + self, + method: str, + path: str, + tenant_subdomain: Optional[str] = None, + **kwargs: Any, + ) -> dict[str, Any]: """Execute an HTTP request using the appropriate session.""" - logger.debug("%s %s", method, path) + logger.debug("%s %s (tenant=%r)", method, path, tenant_subdomain) url = f"{self._config.base_url}{path}" if "params" in kwargs: @@ -185,7 +227,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: if raw_params: url = f"{url}?{urlencode(raw_params, quote_via=quote)}" - session = self._get_session() + session = self._get_session(tenant_subdomain) headers = {"Content-Type": "application/json"} try: diff --git a/src/sap_cloud_sdk/agent_memory/_models.py b/src/sap_cloud_sdk/agent_memory/_models.py index ed41a293..604ed827 100644 --- a/src/sap_cloud_sdk/agent_memory/_models.py +++ b/src/sap_cloud_sdk/agent_memory/_models.py @@ -15,6 +15,13 @@ from typing import Any, Optional +class AccessStrategy(str, Enum): + """Access strategy for tenant-scoped Agent Memory operations.""" + + SUBSCRIBER_ONLY = "SUBSCRIBER_ONLY" + PROVIDER_ONLY = "PROVIDER_ONLY" + + class MessageRole(str, Enum): """Role of the message author.""" diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index f12030fb..de696f9f 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -19,6 +19,7 @@ ) from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory._models import ( + AccessStrategy, Memory, Message, MessageRole, @@ -32,7 +33,9 @@ build_message_filter, extract_value_and_count, ) -from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError +from sap_cloud_sdk.agent_memory.exceptions import ( + AgentMemoryValidationError, +) from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics @@ -87,6 +90,23 @@ def __enter__(self) -> AgentMemoryClient: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() + @staticmethod + def _resolve_tenant( + access_strategy: AccessStrategy, tenant: Optional[str] + ) -> Optional[str]: + """Return the tenant subdomain to use for token derivation. + + Raises: + AgentMemoryValidationError: If ``SUBSCRIBER_ONLY`` is requested but no tenant is given. + """ + if access_strategy is AccessStrategy.SUBSCRIBER_ONLY: + if not tenant: + raise AgentMemoryValidationError( + "tenant is required when access_strategy=SUBSCRIBER_ONLY" + ) + return tenant + return None # PROVIDER_ONLY + # ── Memory operations ────────────────────────────────────────────────────── @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_ADD_MEMORY) @@ -97,6 +117,8 @@ def add_memory( content: str, *, metadata: Optional[dict[str, Any]] = None, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> Memory: """Create a new memory entry. @@ -105,15 +127,19 @@ def add_memory( invoker_id: Identifier of the user or invoker. content: The memory text content. metadata: Optional metadata dict (Map type in OData). + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: The created :class:`Memory`. Raises: - AgentMemoryValidationError: If any required field is empty. + AgentMemoryValidationError: If any required field is empty or tenant is missing + for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, content=content) + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -121,26 +147,38 @@ def add_memory( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post(MEMORIES, json=payload) + data = self._transport.post(MEMORIES, json=payload, tenant_subdomain=tenant_subdomain) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MEMORY) - def get_memory(self, memory_id: str) -> Memory: + def get_memory( + self, + memory_id: str, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, + ) -> Memory: """Retrieve a memory by ID. Args: memory_id: The memory identifier (UUID). + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: The :class:`Memory`. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. - AgentMemoryValidationError: If ``memory_id`` is empty. + AgentMemoryValidationError: If ``memory_id`` is empty or tenant is missing + for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - data = self._transport.get(f"{MEMORIES}({memory_id})") + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + data = self._transport.get( + f"{MEMORIES}({memory_id})", tenant_subdomain=tenant_subdomain + ) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_MEMORY) @@ -150,6 +188,8 @@ def update_memory( *, content: Optional[str] = None, metadata: Optional[dict[str, Any]] = None, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> None: """Update a memory's content and/or metadata. @@ -157,10 +197,13 @@ def update_memory( memory_id: The memory identifier (UUID). content: New content to set. metadata: New metadata dict to set. + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. - AgentMemoryValidationError: If ``memory_id`` is empty or no fields are provided. + AgentMemoryValidationError: If ``memory_id`` is empty, no fields are provided, + or tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) @@ -168,27 +211,42 @@ def update_memory( raise AgentMemoryValidationError( "At least one of 'content' or 'metadata' must be provided" ) + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = {} if content is not None: payload["content"] = content if metadata is not None: payload["metadata"] = metadata - self._transport.patch(f"{MEMORIES}({memory_id})", json=payload) + self._transport.patch( + f"{MEMORIES}({memory_id})", json=payload, tenant_subdomain=tenant_subdomain + ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MEMORY) - def delete_memory(self, memory_id: str) -> None: + def delete_memory( + self, + memory_id: str, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, + ) -> None: """Delete a memory permanently. Args: memory_id: The memory identifier (UUID). + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. - AgentMemoryValidationError: If ``memory_id`` is empty. + AgentMemoryValidationError: If ``memory_id`` is empty or tenant is missing + for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - self._transport.delete(f"{MEMORIES}({memory_id})") + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + self._transport.delete( + f"{MEMORIES}({memory_id})", tenant_subdomain=tenant_subdomain + ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MEMORIES) def list_memories( @@ -199,6 +257,8 @@ def list_memories( filters: Optional[list[FilterDefinition]] = None, limit: int = 50, offset: int = 0, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> list[Memory]: """List memories, optionally filtered by agent and/or invoker. @@ -212,13 +272,15 @@ def list_memories( key-value structured search is not supported. limit: Maximum number of memories to return. Default is ``50``. offset: Number of memories to skip (for pagination). Default is ``0``. + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: List of :class:`Memory` objects. Raises: - AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, or a - filter clause is invalid. + AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, a filter + clause is invalid, or tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ if limit < 1: @@ -227,6 +289,7 @@ def list_memories( raise AgentMemoryValidationError("'offset' must be >= 0") if filters is not None: _validate_filter_clauses(filters, {"metadata", "content"}) + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) params = build_list_params( filter_expr=build_memory_filter( agent_id=agent_id, @@ -236,7 +299,9 @@ def list_memories( top=limit, skip=offset if offset else None, ) - response = self._transport.get(MEMORIES, params=params) + response = self._transport.get( + MEMORIES, params=params, tenant_subdomain=tenant_subdomain + ) items, _ = extract_value_and_count(response) return [Memory.from_dict(item) for item in items] @@ -245,25 +310,34 @@ def count_memories( self, agent_id: Optional[str] = None, invoker_id: Optional[str] = None, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> int: """Count memories matching the given filters. Args: agent_id: Filter by agent identifier. invoker_id: Filter by invoker/user identifier. + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: Total number of matching memories. Raises: + AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) params = build_list_params( filter_expr=build_memory_filter(agent_id=agent_id, invoker_id=invoker_id), top=0, count=True, ) - response = self._transport.get(MEMORIES, params=params) + response = self._transport.get( + MEMORIES, params=params, tenant_subdomain=tenant_subdomain + ) _, total = extract_value_and_count(response) return total or 0 @@ -275,6 +349,9 @@ def search_memories( query: str, threshold: float = 0.6, limit: int = 10, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> list[SearchResult]: """Perform a semantic (vector) search over stored memories. @@ -284,14 +361,16 @@ def search_memories( query: Natural-language search query (5–5000 characters). threshold: Minimum cosine similarity score (0.0–1.0). Default ``0.6``. limit: Maximum number of results (1–50). Default is ``10``. + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: List of :class:`SearchResult` objects. Raises: - AgentMemoryValidationError: If required fields are empty or parameters are + AgentMemoryValidationError: If required fields are empty, parameters are out of range (``query`` must be 5–5000 chars, ``threshold`` 0.0–1.0, - ``limit`` 1–50). + ``limit`` 1–50), or tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, query=query) @@ -303,6 +382,7 @@ def search_memories( raise AgentMemoryValidationError("'threshold' must be between 0.0 and 1.0") if not (1 <= limit <= 50): raise AgentMemoryValidationError("'limit' must be between 1 and 50") + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -310,7 +390,9 @@ def search_memories( "threshold": threshold, "top": limit, } - response = self._transport.post(MEMORY_SEARCH, json=payload) + response = self._transport.post( + MEMORY_SEARCH, json=payload, tenant_subdomain=tenant_subdomain + ) items = response.get("value", []) return [SearchResult.from_dict(item) for item in items] @@ -326,6 +408,8 @@ def add_message( content: str, *, metadata: Optional[dict[str, Any]] = None, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> Message: """Create a new message. @@ -339,12 +423,15 @@ def add_message( role: Author role (USER, ASSISTANT, SYSTEM, TOOL). content: The message text content. metadata: Optional metadata dict. + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: The created :class:`Message`. Raises: - AgentMemoryValidationError: If any required field is empty. + AgentMemoryValidationError: If any required field is empty or tenant is missing + for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty( @@ -353,6 +440,7 @@ def add_message( message_group=message_group, content=content, ) + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -362,42 +450,68 @@ def add_message( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post(MESSAGES, json=payload) + data = self._transport.post( + MESSAGES, json=payload, tenant_subdomain=tenant_subdomain + ) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MESSAGE) - def get_message(self, message_id: str) -> Message: + def get_message( + self, + message_id: str, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, + ) -> Message: """Retrieve a message by ID. Args: message_id: The message identifier (UUID). + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: The :class:`Message`. Raises: AgentMemoryNotFoundError: If no message with the given ID exists. - AgentMemoryValidationError: If ``message_id`` is empty. + AgentMemoryValidationError: If ``message_id`` is empty or tenant is missing + for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - data = self._transport.get(f"{MESSAGES}({message_id})") + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + data = self._transport.get( + f"{MESSAGES}({message_id})", tenant_subdomain=tenant_subdomain + ) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MESSAGE) - def delete_message(self, message_id: str) -> None: + def delete_message( + self, + message_id: str, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, + ) -> None: """Delete a message permanently. Args: message_id: The message identifier (UUID). + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Raises: AgentMemoryNotFoundError: If no message with the given ID exists. - AgentMemoryValidationError: If ``message_id`` is empty. + AgentMemoryValidationError: If ``message_id`` is empty or tenant is missing + for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - self._transport.delete(f"{MESSAGES}({message_id})") + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + self._transport.delete( + f"{MESSAGES}({message_id})", tenant_subdomain=tenant_subdomain + ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MESSAGES) def list_messages( @@ -410,6 +524,8 @@ def list_messages( filters: Optional[list[FilterDefinition]] = None, limit: int = 50, offset: int = 0, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> list[Message]: """List messages, optionally filtered by agent, invoker, group, and role. @@ -425,13 +541,15 @@ def list_messages( key-value structured search is not supported. limit: Maximum number of messages to return. Default is ``50``. offset: Number of messages to skip (for pagination). Default is ``0``. + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Returns: List of :class:`Message` objects. Raises: - AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, or a - filter clause is invalid. + AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, a filter + clause is invalid, or tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ if limit < 1: @@ -440,6 +558,7 @@ def list_messages( raise AgentMemoryValidationError("'offset' must be >= 0") if filters is not None: _validate_filter_clauses(filters, {"metadata", "content"}) + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) params = build_list_params( filter_expr=build_message_filter( agent_id=agent_id, @@ -451,23 +570,36 @@ def list_messages( top=limit, skip=offset if offset else None, ) - response = self._transport.get(MESSAGES, params=params) + response = self._transport.get( + MESSAGES, params=params, tenant_subdomain=tenant_subdomain + ) items, _ = extract_value_and_count(response) return [Message.from_dict(item) for item in items] # ── Admin operations ─────────────────────────────────────────────────────── @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_RETENTION_CONFIG) - def get_retention_config(self) -> RetentionConfig: + def get_retention_config( + self, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, + ) -> RetentionConfig: """Retrieve the data retention configuration (singleton). + Args: + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + Returns: The current :class:`RetentionConfig`. Raises: + AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ - data = self._transport.get(RETENTION_CONFIG) + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + data = self._transport.get(RETENTION_CONFIG, tenant_subdomain=tenant_subdomain) return RetentionConfig.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_RETENTION_CONFIG) @@ -477,6 +609,8 @@ def update_retention_config( message_days: Optional[int] = None, memory_days: Optional[int] = None, usage_log_days: Optional[int] = None, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, ) -> None: """Update the data retention configuration. @@ -488,10 +622,12 @@ def update_retention_config( message_days: How long to keep messages (days). memory_days: How long to keep memories without access (days). usage_log_days: How long to keep access and search logs (days). + access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. + tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. Raises: - AgentMemoryValidationError: If no fields are provided, or any provided - value is negative. + AgentMemoryValidationError: If no fields are provided, any provided value is + negative, or tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ if message_days is None and memory_days is None and usage_log_days is None: @@ -506,6 +642,7 @@ def update_retention_config( ): if value is not None and value < 0: raise AgentMemoryValidationError(f"'{name}' must be >= 0") + tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = {} if message_days is not None: payload["messageDays"] = message_days @@ -513,4 +650,6 @@ def update_retention_config( payload["memoryDays"] = memory_days if usage_log_days is not None: payload["usageLogDays"] = usage_log_days - self._transport.patch(RETENTION_CONFIG, json=payload) + self._transport.patch( + RETENTION_CONFIG, json=payload, tenant_subdomain=tenant_subdomain + ) diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index e2cbdfa7..a32afb70 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -35,6 +35,9 @@ class AgentMemoryConfig: requests are sent without authentication (useful for local development). client_id: The OAuth2 client ID. Optional. client_secret: The OAuth2 client secret. Optional. + identityzone: The provider tenant identity zone subdomain. Required when using + ``SUBSCRIBER_ONLY`` access strategy so the subscriber token URL + can be derived by replacing this value in ``token_url``. timeout: Timeout in seconds for HTTP requests. Default is 30.0. Example — deployed BTP service:: @@ -44,6 +47,7 @@ class AgentMemoryConfig: token_url="https://.authentication./oauth/token", client_id="", client_secret="", + identityzone="", ) Example — local development (no auth):: @@ -55,6 +59,7 @@ class AgentMemoryConfig: token_url: Optional[str] = None client_id: Optional[str] = None client_secret: Optional[str] = None + identityzone: Optional[str] = None timeout: float = 30.0 def __post_init__(self) -> None: @@ -72,6 +77,10 @@ def __post_init__(self) -> None: raise AgentMemoryConfigError( "client_secret must be a non-empty string when provided" ) + if self.identityzone is not None and not self.identityzone: + raise AgentMemoryConfigError( + "identityzone must be a non-empty string when provided" + ) @dataclass @@ -108,6 +117,7 @@ def extract_config(self) -> AgentMemoryConfig: token_url=uaa_data["url"].rstrip("/") + "/oauth/token", client_id=uaa_data["clientid"], client_secret=uaa_data["clientsecret"], + identityzone=uaa_data.get("identityzone"), ) except KeyError as e: raise AgentMemoryConfigError(f"Missing required field in uaa JSON: {e}") diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index caf98fc2..e352bf0d 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -22,6 +22,10 @@ plain text, and the service makes it searchable by meaning. - [Core Concepts](#core-concepts) - [`agent_id`](#agent_id) - [`invoker_id`](#invoker_id) + - [Multitenancy](#multitenancy) + - [AccessStrategy](#accessstrategy) + - [SUBSCRIBER_ONLY (default)](#subscriber_only-default) + - [PROVIDER_ONLY](#provider_only) - [Semantic Search: A Brief Primer](#semantic-search-a-brief-primer) - [Memories](#memories) - [Create a Memory](#create-a-memory) @@ -154,6 +158,60 @@ the application's auth system. Memories and messages are scoped to the combinati Neither value is validated by the service — they are free-form strings. Consistent use across create, read, and search calls is the implementer's responsibility. +## Multitenancy + +The Agent Memory service runs in a multi-tenant BTP environment. By default, every API +call uses a **subscriber-scoped token** — meaning data is isolated to the subscriber tenant +that your application serves. You control this behaviour with the `access_strategy` and +`tenant` keyword arguments available on every client method. + +### AccessStrategy + +```python +from sap_cloud_sdk.agent_memory import AccessStrategy +``` + +| Value | Description | +| --------------------------- | ------------------------------------------------------------------ | +| `SUBSCRIBER_ONLY` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | +| `PROVIDER_ONLY` | Reads and writes against the provider tenant. No `tenant` needed. | + +### SUBSCRIBER_ONLY (default) + +Pass the subscriber tenant subdomain via the `tenant` argument. Omitting `tenant` when +the strategy is `SUBSCRIBER_ONLY` raises `AgentMemoryValidationError`. + +```python +memories = client.list_memories( + agent_id="hr-assistant", + invoker_id="user-42", + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="acme-corp", # subscriber subdomain +) +``` + +The subscriber token URL is derived by replacing the provider's `identityzone` segment +in the configured `token_url` with the `tenant` value. This requires `identityzone` to +be present in the service binding's UAA JSON (standard XSUAA field) or set explicitly in +`AgentMemoryConfig`. + +### PROVIDER_ONLY + +No `tenant` argument is needed. All calls use the provider token. + +```python +memories = client.list_memories( + agent_id="hr-assistant", + invoker_id="user-42", + access_strategy=AccessStrategy.PROVIDER_ONLY, +) +``` + +> [!NOTE] +> The `_FIRST` fallback strategies (`SUBSCRIBER_FIRST`, `PROVIDER_FIRST`) are not yet +> supported and must be evaluated for silent cross-tenant access risks before being +> introduced. + ## Semantic Search: A Brief Primer Texts with different words — or even different languages — can have the same meaning. @@ -491,9 +549,10 @@ See the [Content and metadata filtering](#content-and-metadata-filtering) note u ### Enums -| Enum | Values | -| ------------- | ------------------------------------- | -| `MessageRole` | `USER`, `ASSISTANT`, `SYSTEM`, `TOOL` | +| Enum | Values | +| ---------------- | -------------------------------------------- | +| `MessageRole` | `USER`, `ASSISTANT`, `SYSTEM`, `TOOL` | +| `AccessStrategy` | `SUBSCRIBER_ONLY` (default), `PROVIDER_ONLY` | All models expose a `to_dict()` method that returns a plain dict for logging or forwarding. diff --git a/tests/agent_memory/integration/agentmemory.feature b/tests/agent_memory/integration/agentmemory.feature index e6804f2d..632cc954 100644 --- a/tests/agent_memory/integration/agentmemory.feature +++ b/tests/agent_memory/integration/agentmemory.feature @@ -89,3 +89,93 @@ Feature: Agent Memory Service Integration (v1 API) Given a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker" When I list messages filtered by metadata containing "filter-marker" Then the result should contain the message with content "filter-test-message" + + # ── Subscriber access ──────────────────────────────────────────────────────── + + # ──── Memory CRUD ───────────────────────────────────────────────────────────── + + Scenario: Create a new memory using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + When I create a memory with agent "test-agent" and invoker "test-user" and content "User prefers dark mode" + Then the memory should have a non-empty id + And the memory should have agent_id "test-agent" + And the memory should have invoker_id "test-user" + And the memory should have content "User prefers dark mode" + + Scenario: Get a memory using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Test memory" + When I get the memory by id + Then the returned memory should match the created memory + + Scenario: Update memory content using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Original content" + When I update the memory content to "Updated content" + Then the memory should have content "Updated content" + + Scenario: List memories using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Listed memory" + When I list memories filtered by agent "test-agent" + Then the result should contain at least one memory + And the total count should be a positive number + + Scenario: Delete a memory using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "To be deleted" + When I delete the memory + Then the memory should no longer exist + + # ──── Memory search ─────────────────────────────────────────────────────────── + + Scenario: Search memories using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "The user loves dark mode and dark themes" + When I search for memories with query "dark mode preference" + Then the search result should contain at least one result + And each result should have a non-empty content + + # ──── Message CRUD ──────────────────────────────────────────────────────────── + + Scenario: Create and get a message using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + When I create a message with agent "test-agent" invoker "test-user" group "conv-1" role "USER" content "Hello!" + Then the message should have a non-empty id + And the message should have role "USER" + And the message should have content "Hello!" + + Scenario: List messages using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a message exists with agent "test-agent" invoker "test-user" group "conv-list" role "USER" content "Listed message" + When I list messages filtered by agent "test-agent" and group "conv-list" + Then the result should contain at least one message + And the total count should be a positive number + + Scenario: Delete a message using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a message exists with agent "test-agent" invoker "test-user" group "conv-del" role "USER" content "To be deleted" + When I delete the message + Then the message should no longer exist + + # ──── Bulk / utility operations ─────────────────────────────────────────────── + + Scenario: Count memories using SUBSCRIBER_ONLY access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Count test memory" + When I count memories for agent "test-agent" and invoker "test-user" + Then the memory count should be a positive number + + # ──── Filter ────────────────────────────────────────────────────────────────── + + Scenario: Filter subscriber memories by content substring + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "The user prefers dark mode" + When I list memories filtered by content containing "dark mode" + Then the result should contain the memory with content "The user prefers dark mode" + + Scenario: Filter subscriber messages by metadata substring + Given I use the configured subscriber tenant + And a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker" + When I list messages filtered by metadata containing "filter-marker" + Then the result should contain the message with content "filter-test-message" diff --git a/tests/agent_memory/integration/conftest.py b/tests/agent_memory/integration/conftest.py index 9b26d5c0..1918e633 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -6,8 +6,14 @@ CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_AUTH_URL OAuth2 authorization server base URL CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTID OAuth2 client ID CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTSECRET OAuth2 client secret + +Multitenancy: + + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT Subscriber tenant subdomain + Required for SUBSCRIBER_ONLY tests. When absent those tests are skipped. """ +import os from pathlib import Path import pytest @@ -15,6 +21,7 @@ from sap_cloud_sdk.agent_memory import create_client from sap_cloud_sdk.agent_memory.client import AgentMemoryClient +from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @pytest.fixture(scope="session") @@ -26,5 +33,23 @@ def agent_memory_client() -> AgentMemoryClient: try: return create_client() + except AgentMemoryConfigError as e: + pytest.skip(f"Agent Memory credentials not configured — skipping integration tests: {e}") except Exception as e: pytest.fail(f"Failed to create Agent Memory client for integration tests: {e}") + + +@pytest.fixture(scope="session") +def subscriber_tenant() -> str: + """Return the subscriber tenant subdomain, or skip if not configured.""" + env_file = Path(__file__).parents[3] / ".env_integration_tests" + if env_file.exists(): + load_dotenv(env_file, override=True) + + tenant = os.environ.get("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT", "") + if not tenant: + pytest.skip( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT not set — " + "skipping subscriber tenant tests" + ) + return tenant diff --git a/tests/agent_memory/integration/test_agentmemory_bdd.py b/tests/agent_memory/integration/test_agentmemory_bdd.py index 41460b68..16d77311 100644 --- a/tests/agent_memory/integration/test_agentmemory_bdd.py +++ b/tests/agent_memory/integration/test_agentmemory_bdd.py @@ -14,13 +14,15 @@ """ import pytest -from pytest_bdd import given, scenario, then, when +from pytest_bdd import given, parsers, scenario, then, when +from sap_cloud_sdk.agent_memory import AccessStrategy, FilterDefinition, MessageRole from sap_cloud_sdk.agent_memory.client import AgentMemoryClient -from sap_cloud_sdk.agent_memory import MessageRole # -- Scenarios ----------------------------------------------------------------- +# ── Provider scenarios ──────────────────────────────────────────────────────── + @scenario("agentmemory.feature", "Create a new memory") def test_add_memory(): @@ -92,12 +94,78 @@ def test_filter_messages_by_metadata(): pass +# ── Subscriber scenarios ────────────────────────────────────────────────────── + + +@scenario("agentmemory.feature", "Create a new memory using SUBSCRIBER_ONLY access strategy") +def test_add_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "Get a memory using SUBSCRIBER_ONLY access strategy") +def test_get_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "Update memory content using SUBSCRIBER_ONLY access strategy") +def test_update_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "List memories using SUBSCRIBER_ONLY access strategy") +def test_list_memories_subscriber(): + pass + + +@scenario("agentmemory.feature", "Delete a memory using SUBSCRIBER_ONLY access strategy") +def test_delete_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "Search memories using SUBSCRIBER_ONLY access strategy") +def test_search_memories_subscriber(): + pass + + +@scenario("agentmemory.feature", "Create and get a message using SUBSCRIBER_ONLY access strategy") +def test_add_message_subscriber(): + pass + + +@scenario("agentmemory.feature", "List messages using SUBSCRIBER_ONLY access strategy") +def test_list_messages_subscriber(): + pass + + +@scenario("agentmemory.feature", "Delete a message using SUBSCRIBER_ONLY access strategy") +def test_delete_message_subscriber(): + pass + + +@scenario("agentmemory.feature", "Count memories using SUBSCRIBER_ONLY access strategy") +def test_count_memories_subscriber(): + pass + + +@scenario("agentmemory.feature", "Filter subscriber memories by content substring") +def test_filter_memories_by_content_subscriber(): + pass + + +@scenario("agentmemory.feature", "Filter subscriber messages by metadata substring") +def test_filter_messages_by_metadata_subscriber(): + pass + + # -- Fixtures / state --------------------------------------------------------- @pytest.fixture def context(): - return {} + return { + "access_strategy": AccessStrategy.PROVIDER_ONLY, + "tenant": None, + } # -- Given steps --------------------------------------------------------------- @@ -108,91 +176,52 @@ def configured_client(context, agent_memory_client): context["client"] = agent_memory_client -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Test memory"' -) -def memory_exists_test(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Test memory", - ) +@given("I use the configured subscriber tenant") +def use_configured_subscriber_tenant(context, subscriber_tenant): + context["access_strategy"] = AccessStrategy.SUBSCRIBER_ONLY + context["tenant"] = subscriber_tenant @given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Original content"' -) -def memory_exists_original(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Original content", + parsers.parse( + 'a memory exists with agent "{agent_id}" and invoker "{invoker_id}" and content "{content}"' ) - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Listed memory"' -) -def memory_exists_listed(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Listed memory", - ) - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "To be deleted"' ) -def memory_exists_delete(context, agent_memory_client): +def memory_exists(context, agent_memory_client, agent_id, invoker_id, content): context["client"] = agent_memory_client context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "To be deleted", + agent_id, invoker_id, content, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) @given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "The user loves dark mode and dark themes"' -) -def memory_exists_search(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "The user loves dark mode and dark themes", + parsers.parse( + 'a message exists with agent "{agent_id}" invoker "{invoker_id}" group "{group}" role "{role}" content "{content}"' ) - - -@given( - 'a message exists with agent "test-agent" invoker "test-user" group "conv-list" role "USER" content "Listed message"' ) -def message_exists_list(context, agent_memory_client): +def message_exists(context, agent_memory_client, agent_id, invoker_id, group, role, content): context["client"] = agent_memory_client context["message"] = agent_memory_client.add_message( - "test-agent", - "test-user", - "conv-list", - "USER", - "Listed message", + agent_id, invoker_id, group, role, content, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) @given( - 'a message exists with agent "test-agent" invoker "test-user" group "conv-del" role "USER" content "To be deleted"' + parsers.parse( + 'a message exists with agent "{agent_id}" invoker "{invoker_id}" group "{group}" role "{role}" content "{content}" and metadata "{metadata_value}"' + ) ) -def message_exists_delete(context, agent_memory_client): +def message_exists_with_metadata(context, agent_memory_client, agent_id, invoker_id, group, role, content, metadata_value): context["client"] = agent_memory_client context["message"] = agent_memory_client.add_message( - "test-agent", - "test-user", - "conv-del", - "USER", - "To be deleted", + agent_id, invoker_id, group, role, content, + metadata={"tag": metadata_value}, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) @@ -200,76 +229,110 @@ def message_exists_delete(context, agent_memory_client): @when( - 'I create a memory with agent "test-agent" and invoker "test-user" and content "User prefers dark mode"' + parsers.parse( + 'I create a memory with agent "{agent_id}" and invoker "{invoker_id}" and content "{content}"' + ) ) -def add_memory(context): +def add_memory(context, agent_id, invoker_id, content): client: AgentMemoryClient = context["client"] context["memory"] = client.add_memory( - "test-agent", - "test-user", - "User prefers dark mode", + agent_id, invoker_id, content, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) @when("I get the memory by id") def get_memory(context): client: AgentMemoryClient = context["client"] - context["fetched_memory"] = client.get_memory(context["memory"].id) + context["fetched_memory"] = client.get_memory( + context["memory"].id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) -@when('I update the memory content to "Updated content"') -def update_memory(context): +@when(parsers.parse('I update the memory content to "{content}"')) +def update_memory(context, content): client: AgentMemoryClient = context["client"] - client.update_memory(context["memory"].id, content="Updated content") - context["memory"] = client.get_memory(context["memory"].id) + client.update_memory( + context["memory"].id, content=content, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + context["memory"] = client.get_memory( + context["memory"].id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) -@when('I list memories filtered by agent "test-agent"') -def list_memories(context): +@when(parsers.parse('I list memories filtered by agent "{agent_id}"')) +def list_memories(context, agent_id): client: AgentMemoryClient = context["client"] - context["memories"] = client.list_memories(agent_id="test-agent") - context["total"] = client.count_memories(agent_id="test-agent") + context["memories"] = client.list_memories( + agent_id=agent_id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + context["total"] = client.count_memories( + agent_id=agent_id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) @when("I delete the memory") def delete_memory(context): client: AgentMemoryClient = context["client"] - client.delete_memory(context["memory"].id) + client.delete_memory( + context["memory"].id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) context["deleted_memory_id"] = context["memory"].id -@when('I search for memories with query "dark mode preference"') -def search_memories(context): +@when(parsers.parse('I search for memories with query "{query}"')) +def search_memories(context, query): client: AgentMemoryClient = context["client"] context["search_results"] = client.search_memories( agent_id="test-agent", invoker_id="test-user", - query="dark mode preference", + query=query, threshold=0.5, limit=10, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) @when( - 'I create a message with agent "test-agent" invoker "test-user" group "conv-1" role "USER" content "Hello!"' + parsers.parse( + 'I create a message with agent "{agent_id}" invoker "{invoker_id}" group "{group}" role "{role}" content "{content}"' + ) ) -def add_message(context): +def add_message(context, agent_id, invoker_id, group, role, content): client: AgentMemoryClient = context["client"] context["message"] = client.add_message( - "test-agent", - "test-user", - "conv-1", - MessageRole.USER, - "Hello!", + agent_id, invoker_id, group, MessageRole(role), content, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) -@when('I list messages filtered by agent "test-agent" and group "conv-list"') -def list_messages(context): +@when( + parsers.parse( + 'I list messages filtered by agent "{agent_id}" and group "{group}"' + ) +) +def list_messages(context, agent_id, group): client: AgentMemoryClient = context["client"] context["messages"] = client.list_messages( - agent_id="test-agent", - message_group="conv-list", + agent_id=agent_id, + message_group=group, + access_strategy=context["access_strategy"], + tenant=context["tenant"], ) context["total"] = len(context["messages"]) @@ -277,10 +340,77 @@ def list_messages(context): @when("I delete the message") def delete_message(context): client: AgentMemoryClient = context["client"] - client.delete_message(context["message"].id) + client.delete_message( + context["message"].id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) context["deleted_message_id"] = context["message"].id +@when("I get the retention config") +def get_retention_config(context): + client: AgentMemoryClient = context["client"] + context["retention_config"] = client.get_retention_config( + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + + +@when("I update the retention config with message_days 30 and memory_days 90") +def update_retention_config(context): + client: AgentMemoryClient = context["client"] + client.update_retention_config( + message_days=30, memory_days=90, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + context["retention_config"] = client.get_retention_config( + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + + +@when( + parsers.parse( + 'I count memories for agent "{agent_id}" and invoker "{invoker_id}"' + ) +) +def count_memories(context, agent_id, invoker_id): + client: AgentMemoryClient = context["client"] + context["memory_count"] = client.count_memories( + agent_id=agent_id, + invoker_id=invoker_id, + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + + +@when(parsers.parse('I list memories filtered by content containing "{substring}"')) +def list_memories_by_content(context, substring): + client: AgentMemoryClient = context["client"] + context["memories"] = client.list_memories( + agent_id="test-agent", + invoker_id="test-user", + filters=[FilterDefinition(target="content", contains=substring)], + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + + +@when(parsers.parse('I list messages filtered by metadata containing "{substring}"')) +def list_messages_by_metadata(context, substring): + client: AgentMemoryClient = context["client"] + context["messages"] = client.list_messages( + agent_id="test-agent", + invoker_id="test-user", + message_group="conv-filter", + filters=[FilterDefinition(target="metadata", contains=substring)], + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) + + # -- Then steps ---------------------------------------------------------------- @@ -289,24 +419,19 @@ def check_memory_id(context): assert context["memory"].id != "" -@then('the memory should have agent_id "test-agent"') -def check_memory_agent_id(context): - assert context["memory"].agent_id == "test-agent" +@then(parsers.parse('the memory should have agent_id "{agent_id}"')) +def check_memory_agent_id(context, agent_id): + assert context["memory"].agent_id == agent_id -@then('the memory should have invoker_id "test-user"') -def check_memory_invoker_id(context): - assert context["memory"].invoker_id == "test-user" +@then(parsers.parse('the memory should have invoker_id "{invoker_id}"')) +def check_memory_invoker_id(context, invoker_id): + assert context["memory"].invoker_id == invoker_id -@then('the memory should have content "User prefers dark mode"') -def check_memory_content_dark(context): - assert context["memory"].content == "User prefers dark mode" - - -@then('the memory should have content "Updated content"') -def check_memory_content_updated(context): - assert context["memory"].content == "Updated content" +@then(parsers.parse('the memory should have content "{content}"')) +def check_memory_content(context, content): + assert context["memory"].content == content @then("the returned memory should match the created memory") @@ -332,7 +457,11 @@ def check_memory_deleted(context): client: AgentMemoryClient = context["client"] with pytest.raises(AgentMemoryNotFoundError): - client.get_memory(context["deleted_memory_id"]) + client.get_memory( + context["deleted_memory_id"], + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) @then("the search result should contain at least one result") @@ -356,9 +485,9 @@ def check_message_role(context): assert context["message"].role == "USER" -@then('the message should have content "Hello!"') -def check_message_content(context): - assert context["message"].content == "Hello!" +@then(parsers.parse('the message should have content "{content}"')) +def check_message_content(context, content): + assert context["message"].content == content @then("the result should contain at least one message") @@ -372,23 +501,11 @@ def check_message_deleted(context): client: AgentMemoryClient = context["client"] with pytest.raises(AgentMemoryNotFoundError): - client.get_message(context["deleted_message_id"]) - - -# -- Admin: Retention Config steps --------------------------------------------- - - -@when("I get the retention config") -def get_retention_config(context): - client: AgentMemoryClient = context["client"] - context["retention_config"] = client.get_retention_config() - - -@when("I update the retention config with message_days 30 and memory_days 90") -def update_retention_config(context): - client: AgentMemoryClient = context["client"] - client.update_retention_config(message_days=30, memory_days=90) - context["retention_config"] = client.get_retention_config() + client.get_message( + context["deleted_message_id"], + access_strategy=context["access_strategy"], + tenant=context["tenant"], + ) @then("the retention config should have a non-empty id") @@ -406,97 +523,18 @@ def check_retention_memory_days(context): assert context["retention_config"].memory_days == 90 -# -- Bulk / utility steps ------------------------------------------------------- - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Count test memory"' -) -def memory_exists_count(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Count test memory", - ) - - -@when('I count memories for agent "test-agent" and invoker "test-user"') -def count_memories(context): - client: AgentMemoryClient = context["client"] - context["memory_count"] = client.count_memories( - agent_id="test-agent", - invoker_id="test-user", - ) - - @then("the memory count should be a positive number") def check_memory_count_positive(context): assert context["memory_count"] >= 1 -# -- Filter steps --------------------------------------------------------------- - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "The user prefers dark mode"' -) -def memory_exists_dark_mode(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "The user prefers dark mode", - ) - - -@given( - 'a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker"' -) -def message_exists_filter(context, agent_memory_client): - context["client"] = agent_memory_client - context["message"] = agent_memory_client.add_message( - "test-agent", - "test-user", - "conv-filter", - "USER", - "filter-test-message", - metadata={"tag": "filter-marker"}, - ) - - -@when('I list memories filtered by content containing "dark mode"') -def list_memories_by_content(context): - from sap_cloud_sdk.agent_memory import FilterDefinition - - client: AgentMemoryClient = context["client"] - context["memories"] = client.list_memories( - agent_id="test-agent", - invoker_id="test-user", - filters=[FilterDefinition(target="content", contains="dark mode")], - ) - - -@when('I list messages filtered by metadata containing "filter-marker"') -def list_messages_by_metadata(context): - from sap_cloud_sdk.agent_memory import FilterDefinition - - client: AgentMemoryClient = context["client"] - context["messages"] = client.list_messages( - agent_id="test-agent", - invoker_id="test-user", - message_group="conv-filter", - filters=[FilterDefinition(target="metadata", contains="filter-marker")], - ) - - -@then('the result should contain the memory with content "The user prefers dark mode"') -def check_memory_content_in_results(context): +@then(parsers.parse('the result should contain the memory with content "{content}"')) +def check_memory_content_in_results(context, content): contents = [m.content for m in context["memories"]] - assert "The user prefers dark mode" in contents + assert content in contents -@then('the result should contain the message with content "filter-test-message"') -def check_message_content_in_results(context): +@then(parsers.parse('the result should contain the message with content "{content}"')) +def check_message_content_in_results(context, content): contents = [m.content for m in context["messages"]] - assert "filter-test-message" in contents + assert content in contents diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index b3dbbdd3..7961fe3d 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -11,6 +11,7 @@ ) from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory._models import ( + AccessStrategy, Memory, Message, MessageRole, @@ -22,7 +23,6 @@ from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError - def _make_client() -> tuple[AgentMemoryClient, MagicMock]: """Return an AgentMemoryClient with a mocked transport layer.""" transport = MagicMock(spec=HttpTransport) @@ -58,6 +58,97 @@ def test_reads_env_when_no_config_provided(self, monkeypatch): assert isinstance(client, AgentMemoryClient) +# ── Access strategy ─────────────────────────────────────────────────────────── + +class TestAccessStrategy: + + def test_subscriber_only_with_tenant_resolves_to_subdomain(self): + """SUBSCRIBER_ONLY with a tenant returns the tenant subdomain.""" + subdomain = AgentMemoryClient._resolve_tenant( + AccessStrategy.SUBSCRIBER_ONLY, "my-tenant" + ) + assert subdomain == "my-tenant" + + def test_subscriber_only_without_tenant_raises(self): + """SUBSCRIBER_ONLY without a tenant raises AgentMemoryValidationError.""" + with pytest.raises(AgentMemoryValidationError, match="tenant"): + AgentMemoryClient._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, None) + + def test_subscriber_only_with_empty_tenant_raises(self): + """SUBSCRIBER_ONLY with an empty tenant string raises AgentMemoryValidationError.""" + with pytest.raises(AgentMemoryValidationError, match="tenant"): + AgentMemoryClient._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, "") + + def test_provider_only_without_tenant_resolves_to_none(self): + """PROVIDER_ONLY without a tenant resolves to None (no subdomain substitution).""" + subdomain = AgentMemoryClient._resolve_tenant(AccessStrategy.PROVIDER_ONLY, None) + assert subdomain is None + + def test_provider_only_ignores_tenant(self): + """PROVIDER_ONLY ignores any tenant value and always returns None.""" + subdomain = AgentMemoryClient._resolve_tenant(AccessStrategy.PROVIDER_ONLY, "ignored") + assert subdomain is None + + def test_add_memory_subscriber_only_passes_tenant_to_transport(self): + """add_memory with SUBSCRIBER_ONLY passes tenant_subdomain to transport.""" + client, mock_transport = _make_client() + mock_transport.post.return_value = { + "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + } + + client.add_memory( + "a", "u", "x", + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="sub-tenant", + ) + + assert mock_transport.post.call_args[1]["tenant_subdomain"] == "sub-tenant" + + def test_add_memory_provider_only_passes_none_to_transport(self): + """add_memory with PROVIDER_ONLY passes tenant_subdomain=None to transport.""" + client, mock_transport = _make_client() + mock_transport.post.return_value = { + "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + } + + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER_ONLY) + + assert mock_transport.post.call_args[1]["tenant_subdomain"] is None + + def test_add_memory_subscriber_only_without_tenant_raises(self): + """add_memory with SUBSCRIBER_ONLY and no tenant raises before transport call.""" + client, mock_transport = _make_client() + + with pytest.raises(AgentMemoryValidationError, match="tenant"): + client.add_memory( + "a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER_ONLY + ) + + mock_transport.post.assert_not_called() + + def test_list_memories_subscriber_only_passes_tenant_to_transport(self): + """list_memories with SUBSCRIBER_ONLY passes tenant_subdomain to transport.""" + client, mock_transport = _make_client() + mock_transport.get.return_value = {"value": []} + + client.list_memories( + agent_id="a", + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="sub", + ) + + assert mock_transport.get.call_args[1]["tenant_subdomain"] == "sub" + + def test_list_memories_provider_only_passes_none_to_transport(self): + """list_memories with PROVIDER_ONLY passes tenant_subdomain=None to transport.""" + client, mock_transport = _make_client() + mock_transport.get.return_value = {"value": []} + + client.list_memories(agent_id="a", access_strategy=AccessStrategy.PROVIDER_ONLY) + + assert mock_transport.get.call_args[1]["tenant_subdomain"] is None + + # ── Memory CRUD operations ──────────────────────────────────────────────────── @@ -74,7 +165,7 @@ def test_add_memory_posts_correct_payload(self): "createType": "DIRECT", } - memory = client.add_memory("agent-a", "user-b", "some memory") + memory = client.add_memory("agent-a", "user-b", "some memory", access_strategy=AccessStrategy.PROVIDER_ONLY) assert isinstance(memory, Memory) assert memory.id == "mem-1" @@ -90,7 +181,7 @@ def test_add_memory_with_metadata(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x", metadata={"key": "val"}) + client.add_memory("a", "u", "x", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -102,7 +193,7 @@ def test_add_memory_excludes_none_optionals(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x") + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -115,7 +206,7 @@ def test_add_memory_posts_to_memories_endpoint(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x") + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER_ONLY) call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORIES @@ -127,7 +218,7 @@ def test_get_memory_calls_get_with_memory_id(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "hello", } - memory = client.get_memory("mem-1") + memory = client.get_memory("mem-1", access_strategy=AccessStrategy.PROVIDER_ONLY) assert memory.id == "mem-1" call_path = mock_transport.get.call_args[0][0] @@ -137,7 +228,7 @@ def test_update_memory_calls_patch(self): """update_memory sends a PATCH with the updated fields.""" client, mock_transport = _make_client() - client.update_memory("mem-1", content="updated") + client.update_memory("mem-1", content="updated", access_strategy=AccessStrategy.PROVIDER_ONLY) mock_transport.patch.assert_called_once() payload = mock_transport.patch.call_args[1]["json"] @@ -147,7 +238,7 @@ def test_update_memory_excludes_none_fields(self): """update_memory omits None-valued optional fields from the PATCH body.""" client, mock_transport = _make_client() - client.update_memory("mem-1", content="x") + client.update_memory("mem-1", content="x", access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.patch.call_args[1]["json"] assert "metadata" not in payload @@ -156,7 +247,7 @@ def test_update_memory_with_metadata_only(self): """update_memory supports updating metadata without content.""" client, mock_transport = _make_client() - client.update_memory("mem-1", metadata={"key": "new-meta"}) + client.update_memory("mem-1", metadata={"key": "new-meta"}, access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.patch.call_args[1]["json"] assert payload["metadata"] == {"key": "new-meta"} @@ -166,7 +257,7 @@ def test_delete_memory_calls_delete(self): """delete_memory sends a DELETE to the correct path.""" client, mock_transport = _make_client() - client.delete_memory("mem-1") + client.delete_memory("mem-1", access_strategy=AccessStrategy.PROVIDER_ONLY) mock_transport.delete.assert_called_once() call_path = mock_transport.delete.call_args[0][0] @@ -187,7 +278,7 @@ def test_returns_list_of_memories(self): ], } - memories = client.list_memories(agent_id="a", invoker_id="u") + memories = client.list_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER_ONLY) assert len(memories) == 1 assert isinstance(memories[0], Memory) @@ -197,7 +288,7 @@ def test_passes_filter_for_agent_and_invoker(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="agent-x", invoker_id="user-y") + client.list_memories(agent_id="agent-x", invoker_id="user-y", access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert "agentID eq 'agent-x'" in params["$filter"] @@ -208,7 +299,7 @@ def test_default_limit_is_50(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories() + client.list_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "50" @@ -218,7 +309,7 @@ def test_custom_limit(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(limit=5) + client.list_memories(limit=5, access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "5" @@ -228,7 +319,7 @@ def test_empty_list(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - memories = client.list_memories() + memories = client.list_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) assert len(memories) == 0 @@ -237,7 +328,7 @@ def test_offset_passes_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(offset=50) + client.list_memories(offset=50, access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$skip"] == "50" @@ -247,7 +338,7 @@ def test_zero_offset_omits_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories() + client.list_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert "$skip" not in params @@ -259,6 +350,7 @@ def test_filter_metadata_contains_adds_contains_clause(self): client.list_memories( filters=[FilterDefinition(target="metadata", contains="john")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -271,6 +363,7 @@ def test_filter_content_contains_adds_contains_clause(self): client.list_memories( filters=[FilterDefinition(target="content", contains="dark mode")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -286,6 +379,7 @@ def test_filter_multiple_clauses_joined_with_and(self): FilterDefinition(target="metadata", contains="john"), FilterDefinition(target="content", contains="user prefers"), ], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -303,6 +397,7 @@ def test_filter_combines_with_agent_and_invoker_filters(self): agent_id="my-agent", invoker_id="user-1", filters=[FilterDefinition(target="content", contains="dark mode")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -316,7 +411,7 @@ def test_filter_none_does_not_change_behaviour(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="a", invoker_id="u", filters=None) + client.list_memories(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -329,7 +424,7 @@ def test_returns_count_from_response(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 42} - total = client.count_memories(agent_id="a", invoker_id="u") + total = client.count_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER_ONLY) assert total == 42 @@ -338,7 +433,7 @@ def test_sends_top_0_and_count_true(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 0} - client.count_memories() + client.count_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "0" @@ -349,7 +444,7 @@ def test_passes_filter_when_agent_and_invoker_provided(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 3} - client.count_memories(agent_id="agt", invoker_id="usr") + client.count_memories(agent_id="agt", invoker_id="usr", access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert "agentID eq 'agt'" in params["$filter"] @@ -360,7 +455,7 @@ def test_returns_zero_when_count_missing(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - total = client.count_memories() + total = client.count_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) assert total == 0 @@ -380,7 +475,7 @@ def test_returns_results_in_api_order(self): ] } - results = client.search_memories("a", "u", "test query") + results = client.search_memories("a", "u", "test query", access_strategy=AccessStrategy.PROVIDER_ONLY) assert len(results) == 2 assert all(isinstance(r, SearchResult) for r in results) @@ -392,7 +487,7 @@ def test_posts_correct_payload(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5) + client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5, access_strategy=AccessStrategy.PROVIDER_ONLY) call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORY_SEARCH @@ -408,7 +503,7 @@ def test_empty_results(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - results = client.search_memories("a", "u", "empty query") + results = client.search_memories("a", "u", "empty query", access_strategy=AccessStrategy.PROVIDER_ONLY) assert len(results) == 0 @@ -417,7 +512,7 @@ def test_uses_default_threshold_and_limit(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("a", "u", "query") + client.search_memories("a", "u", "query", access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.post.call_args[1]["json"] assert payload["threshold"] == 0.6 @@ -444,6 +539,7 @@ def test_add_message_posts_correct_payload(self): message = client.add_message( "agent-a", "user-b", "conv-1", MessageRole.USER, "Hello!", + access_strategy=AccessStrategy.PROVIDER_ONLY, ) assert isinstance(message, Message) @@ -464,7 +560,7 @@ def test_add_message_posts_to_messages_endpoint(self): "messageGroup": "g", "role": "USER", "content": "hi", } - client.add_message("a", "u", "g", MessageRole.USER, "hi") + client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) call_path = mock_transport.post.call_args[0][0] assert call_path == MESSAGES @@ -478,7 +574,7 @@ def test_add_message_with_metadata(self): "metadata": {"key": "val"}, } - client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}) + client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -491,7 +587,7 @@ def test_add_message_excludes_none_metadata(self): "messageGroup": "g", "role": "USER", "content": "hi", } - client.add_message("a", "u", "g", MessageRole.USER, "hi") + client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -504,7 +600,7 @@ def test_get_message_calls_get_with_message_id(self): "messageGroup": "g", "role": "USER", "content": "hi", } - message = client.get_message("msg-1") + message = client.get_message("msg-1", access_strategy=AccessStrategy.PROVIDER_ONLY) assert message.id == "msg-1" call_path = mock_transport.get.call_args[0][0] @@ -514,7 +610,7 @@ def test_delete_message_calls_delete(self): """delete_message sends a DELETE to the correct path.""" client, mock_transport = _make_client() - client.delete_message("msg-1") + client.delete_message("msg-1", access_strategy=AccessStrategy.PROVIDER_ONLY) mock_transport.delete.assert_called_once() call_path = mock_transport.delete.call_args[0][0] @@ -538,7 +634,7 @@ def test_returns_list_of_messages(self): ], } - messages = client.list_messages(agent_id="a", invoker_id="u") + messages = client.list_messages(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER_ONLY) assert len(messages) == 1 assert isinstance(messages[0], Message) @@ -551,6 +647,7 @@ def test_passes_convenience_filters(self): client.list_messages( agent_id="a", invoker_id="u", message_group="conv-1", role="USER", + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -565,7 +662,7 @@ def test_default_limit_is_50(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages() + client.list_messages(access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "50" @@ -575,7 +672,7 @@ def test_custom_limit(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(limit=20) + client.list_messages(limit=20, access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "20" @@ -585,7 +682,7 @@ def test_empty_list(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - messages = client.list_messages() + messages = client.list_messages(access_strategy=AccessStrategy.PROVIDER_ONLY) assert len(messages) == 0 @@ -594,7 +691,7 @@ def test_offset_passes_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(offset=100) + client.list_messages(offset=100, access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$skip"] == "100" @@ -604,7 +701,7 @@ def test_zero_offset_omits_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages() + client.list_messages(access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert "$skip" not in params @@ -616,6 +713,7 @@ def test_filter_metadata_contains_adds_contains_clause(self): client.list_messages( filters=[FilterDefinition(target="metadata", contains="demo-app")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -628,6 +726,7 @@ def test_filter_content_contains_adds_contains_clause(self): client.list_messages( filters=[FilterDefinition(target="content", contains="invoice")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -643,6 +742,7 @@ def test_filter_multiple_clauses_joined_with_and(self): FilterDefinition(target="metadata", contains="john"), FilterDefinition(target="content", contains="user prefers"), ], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -662,6 +762,7 @@ def test_filter_combines_with_convenience_filters(self): message_group="g", role="USER", filters=[FilterDefinition(target="content", contains="hello")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) params = mock_transport.get.call_args[1]["params"] @@ -677,7 +778,7 @@ def test_filter_none_does_not_change_behaviour(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(agent_id="a", invoker_id="u", filters=None) + client.list_messages(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER_ONLY) params = mock_transport.get.call_args[1]["params"] assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -698,7 +799,7 @@ def test_get_retention_config(self): "updateTimestamp": "2025-01-02T00:00:00Z", } - rc = client.get_retention_config() + rc = client.get_retention_config(access_strategy=AccessStrategy.PROVIDER_ONLY) assert isinstance(rc, RetentionConfig) assert rc.id == 1 @@ -712,7 +813,7 @@ def test_update_retention_config(self): """update_retention_config sends PATCH with updated fields.""" client, mock_transport = _make_client() - client.update_retention_config(message_days=60) + client.update_retention_config(message_days=60, access_strategy=AccessStrategy.PROVIDER_ONLY) mock_transport.patch.assert_called_once() call_path = mock_transport.patch.call_args[0][0] @@ -725,7 +826,7 @@ def test_update_retention_config_excludes_none_fields(self): """update_retention_config omits None-valued fields from PATCH body.""" client, mock_transport = _make_client() - client.update_retention_config(memory_days=90, usage_log_days=180) + client.update_retention_config(memory_days=90, usage_log_days=180, access_strategy=AccessStrategy.PROVIDER_ONLY) payload = mock_transport.patch.call_args[1]["json"] assert "messageDays" not in payload @@ -766,55 +867,55 @@ def test_add_memory_raises_for_empty_agent_id(self): """add_memory raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.add_memory("", "user-1", "content") + client.add_memory("", "user-1", "content", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_add_memory_raises_for_empty_invoker_id(self): """add_memory raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.add_memory("agent-1", "", "content") + client.add_memory("agent-1", "", "content", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_add_memory_raises_for_empty_content(self): """add_memory raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): - client.add_memory("agent-1", "user-1", "") + client.add_memory("agent-1", "user-1", "", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_get_memory_raises_for_empty_id(self): """get_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.get_memory("") + client.get_memory("", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_update_memory_raises_for_empty_id(self): """update_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.update_memory("", content="new content") + client.update_memory("", content="new content", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_update_memory_raises_when_no_fields_provided(self): """update_memory raises AgentMemoryValidationError when neither content nor metadata is provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): - client.update_memory("uuid-123") + client.update_memory("uuid-123", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_delete_memory_raises_for_empty_id(self): """delete_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.delete_memory("") + client.delete_memory("", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_list_memories_raises_for_zero_limit(self): """list_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.list_memories(limit=0) + client.list_memories(limit=0, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_list_memories_raises_for_negative_offset(self): """list_memories raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): - client.list_memories(offset=-1) + client.list_memories(offset=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) class TestSearchMemoriesValidation: @@ -823,57 +924,57 @@ def test_raises_for_empty_agent_id(self): """search_memories raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.search_memories("", "user-1", "what do I know about Python?") + client.search_memories("", "user-1", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_empty_invoker_id(self): """search_memories raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.search_memories("agent-1", "", "what do I know about Python?") + client.search_memories("agent-1", "", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_query_too_short(self): """search_memories raises AgentMemoryValidationError when query has fewer than 5 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): - client.search_memories("agent-1", "user-1", "hi") + client.search_memories("agent-1", "user-1", "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_query_too_long(self): """search_memories raises AgentMemoryValidationError when query exceeds 5000 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): - client.search_memories("agent-1", "user-1", "x" * 5001) + client.search_memories("agent-1", "user-1", "x" * 5001, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_threshold_below_zero(self): """search_memories raises AgentMemoryValidationError when threshold < 0.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): - client.search_memories("a", "u", "valid query here", threshold=-0.1) + client.search_memories("a", "u", "valid query here", threshold=-0.1, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_threshold_above_one(self): """search_memories raises AgentMemoryValidationError when threshold > 1.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): - client.search_memories("a", "u", "valid query here", threshold=1.1) + client.search_memories("a", "u", "valid query here", threshold=1.1, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_limit_zero(self): """search_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.search_memories("a", "u", "valid query here", limit=0) + client.search_memories("a", "u", "valid query here", limit=0, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_raises_for_limit_above_fifty(self): """search_memories raises AgentMemoryValidationError when limit exceeds 50.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.search_memories("a", "u", "valid query here", limit=51) + client.search_memories("a", "u", "valid query here", limit=51, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_boundary_values_are_accepted(self): """search_memories accepts boundary values: 5-char query, threshold 0.0/1.0, limit 1/50.""" client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("a", "u", "hello", threshold=0.0, limit=1) - client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50) + client.search_memories("a", "u", "hello", threshold=0.0, limit=1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50, access_strategy=AccessStrategy.PROVIDER_ONLY) assert mock_transport.post.call_count == 2 @@ -884,49 +985,49 @@ def test_add_message_raises_for_empty_agent_id(self): """add_message raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.add_message("", "u", "grp", MessageRole.USER, "hi") + client.add_message("", "u", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_add_message_raises_for_empty_invoker_id(self): """add_message raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.add_message("a", "", "grp", MessageRole.USER, "hi") + client.add_message("a", "", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_add_message_raises_for_empty_message_group(self): """add_message raises AgentMemoryValidationError when message_group is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_group"): - client.add_message("a", "u", "", MessageRole.USER, "hi") + client.add_message("a", "u", "", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_add_message_raises_for_empty_content(self): """add_message raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): - client.add_message("a", "u", "grp", MessageRole.USER, "") + client.add_message("a", "u", "grp", MessageRole.USER, "", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_get_message_raises_for_empty_id(self): """get_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): - client.get_message("") + client.get_message("", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_delete_message_raises_for_empty_id(self): """delete_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): - client.delete_message("") + client.delete_message("", access_strategy=AccessStrategy.PROVIDER_ONLY) def test_list_messages_raises_for_zero_limit(self): """list_messages raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.list_messages(limit=0) + client.list_messages(limit=0, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_list_messages_raises_for_negative_offset(self): """list_messages raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): - client.list_messages(offset=-1) + client.list_messages(offset=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) class TestRetentionConfigValidation: @@ -935,31 +1036,31 @@ def test_update_raises_when_no_fields_provided(self): """update_retention_config raises AgentMemoryValidationError when no fields are provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): - client.update_retention_config() + client.update_retention_config(access_strategy=AccessStrategy.PROVIDER_ONLY) def test_update_raises_for_negative_message_days(self): """update_retention_config raises AgentMemoryValidationError when message_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_days"): - client.update_retention_config(message_days=-1) + client.update_retention_config(message_days=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_update_raises_for_negative_memory_days(self): """update_retention_config raises AgentMemoryValidationError when memory_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_days"): - client.update_retention_config(memory_days=-1) + client.update_retention_config(memory_days=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_update_raises_for_negative_usage_log_days(self): """update_retention_config raises AgentMemoryValidationError when usage_log_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="usage_log_days"): - client.update_retention_config(usage_log_days=-1) + client.update_retention_config(usage_log_days=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) def test_update_accepts_zero_values(self): """update_retention_config accepts 0 as a valid value (disables cleanup).""" client, mock_transport = _make_client() - client.update_retention_config(memory_days=0) + client.update_retention_config(memory_days=0, access_strategy=AccessStrategy.PROVIDER_ONLY) mock_transport.patch.assert_called_once() @@ -975,6 +1076,7 @@ def test_list_memories_raises_for_unsupported_target(self): with pytest.raises(AgentMemoryValidationError, match="target"): client.list_memories( filters=[FilterDefinition(target="agentID", contains="x")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) def test_list_memories_raises_for_empty_contains(self): @@ -983,6 +1085,7 @@ def test_list_memories_raises_for_empty_contains(self): with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_memories( filters=[FilterDefinition(target="content", contains="")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) def test_list_messages_raises_for_unsupported_target(self): @@ -991,6 +1094,7 @@ def test_list_messages_raises_for_unsupported_target(self): with pytest.raises(AgentMemoryValidationError, match="target"): client.list_messages( filters=[FilterDefinition(target="role", contains="x")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) def test_list_messages_raises_for_empty_contains(self): @@ -999,4 +1103,5 @@ def test_list_messages_raises_for_empty_contains(self): with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_messages( filters=[FilterDefinition(target="metadata", contains="")], + access_strategy=AccessStrategy.PROVIDER_ONLY, ) diff --git a/tests/agent_memory/unit/test_config.py b/tests/agent_memory/unit/test_config.py index d3e895a1..40e9b2ea 100644 --- a/tests/agent_memory/unit/test_config.py +++ b/tests/agent_memory/unit/test_config.py @@ -51,6 +51,14 @@ def test_timeout_default(self): config = AgentMemoryConfig(base_url="http://localhost:8080") assert config.timeout == 30.0 + def test_raises_when_identityzone_empty_string(self): + with pytest.raises(AgentMemoryConfigError, match="identityzone"): + AgentMemoryConfig(base_url="http://localhost", identityzone="") + + def test_identityzone_defaults_to_none(self): + config = AgentMemoryConfig(base_url="http://localhost:8080") + assert config.identityzone is None + def test_valid_config_with_all_fields_does_not_raise(self): AgentMemoryConfig( base_url="https://memory.example.com", @@ -102,6 +110,20 @@ def test_extract_config_raises_on_missing_json_key(self): with pytest.raises(AgentMemoryConfigError, match="Missing required field in uaa JSON"): BindingData(application_url="https://memory.example.com", uaa=uaa).extract_config() + def test_extract_config_maps_identityzone_when_present(self): + uaa = json.dumps({ + "url": "https://my-zone.authentication.eu12.hana.ondemand.com", + "clientid": "c", + "clientsecret": "s", + "identityzone": "my-zone", + }) + config = BindingData(application_url="https://memory.example.com", uaa=uaa).extract_config() + assert config.identityzone == "my-zone" + + def test_extract_config_identityzone_is_none_when_absent(self): + config = BindingData(application_url="https://memory.example.com", uaa=_VALID_UAA).extract_config() + assert config.identityzone is None + def test_extract_config_ignores_extra_uaa_fields(self): uaa = json.dumps({ "apiurl": "https://api.authentication.eu12.hana.ondemand.com", @@ -119,6 +141,7 @@ def test_extract_config_ignores_extra_uaa_fields(self): assert config.token_url == "https://auth.example.com/oauth/token" assert config.client_id == "my-client" assert config.client_secret == "my-secret" + assert config.identityzone == "my-zone" def test_extract_config_raises_on_empty_uaa_object(self): with pytest.raises(AgentMemoryConfigError, match="Missing required field in uaa JSON"): diff --git a/tests/agent_memory/unit/test_http_transport.py b/tests/agent_memory/unit/test_http_transport.py index 282abe37..74008c44 100644 --- a/tests/agent_memory/unit/test_http_transport.py +++ b/tests/agent_memory/unit/test_http_transport.py @@ -13,13 +13,18 @@ from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryHttpError, AgentMemoryNotFoundError -def _config(with_auth: bool = True) -> AgentMemoryConfig: +def _config( + with_auth: bool = True, + identityzone: str | None = None, + token_url: str = "http://auth.example.com/oauth/token", +) -> AgentMemoryConfig: if with_auth: return AgentMemoryConfig( base_url="http://localhost:8080", - token_url="http://localhost:8080/oauth/token", + token_url=token_url, client_id="client-id", client_secret="client-secret", + identityzone=identityzone, ) return AgentMemoryConfig(base_url="http://localhost:8080") @@ -74,7 +79,7 @@ def test_uses_plain_session_when_no_token_url(self): class TestTokenAcquisition: def test_token_is_fetched_and_cached(self): - """fetch_token is called only once across multiple requests.""" + """fetch_token is called only once across multiple requests with the same tenant.""" with patch( "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" ) as MockOAuth, patch( @@ -110,11 +115,11 @@ def test_expired_token_triggers_refetch(self): mock_oauth.request.return_value = _mock_response(200, {}) transport = HttpTransport(_config()) - transport._token_expires_at = datetime.now() - timedelta(seconds=1) - transport.get("/test") + # Force the cache to have an expired entry + past = datetime.now() - timedelta(seconds=1) + transport._oauth_cache[None] = (mock_oauth, past) transport.get("/test") - # Two fetches: one for each request since we started with an expired timestamp assert mock_oauth.fetch_token.call_count >= 1 def test_token_expiry_uses_buffer(self): @@ -135,11 +140,11 @@ def test_token_expiry_uses_buffer(self): transport = HttpTransport(_config()) transport.get("/test") + _, expires_at = transport._oauth_cache[None] expected_max = datetime.now() + timedelta( seconds=3600 - _TOKEN_EXPIRY_BUFFER_SECONDS + 5 ) - assert transport._token_expires_at is not None - assert transport._token_expires_at < expected_max + assert expires_at < expected_max def test_token_fetch_failure_raises_http_error(self): """Failed token fetch raises AgentMemoryHttpError.""" @@ -157,6 +162,110 @@ def test_token_fetch_failure_raises_http_error(self): transport.get("/test") +# ── Per-tenant token derivation ─────────────────────────────────────────────── + + +class TestTenantTokenDerivation: + + def test_subscriber_token_url_replaces_identityzone(self): + """When tenant_subdomain is provided, identityzone is replaced in the token URL.""" + token_url = "http://provider-zone.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="provider-zone", token_url=token_url) + + captured_urls = [] + + def fake_fetch_token(**kwargs): + captured_urls.append(kwargs["token_url"]) + return {"access_token": "tok", "expires_in": 3600} + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.side_effect = fake_fetch_token + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test", tenant_subdomain="subscriber-zone") + + assert len(captured_urls) == 1 + assert "subscriber-zone" in captured_urls[0] + assert "provider-zone" not in captured_urls[0] + + def test_provider_token_url_unchanged_when_no_tenant(self): + """Without tenant_subdomain, the provider token URL is used as-is.""" + token_url = "http://provider-zone.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="provider-zone", token_url=token_url) + + captured_urls = [] + + def fake_fetch_token(**kwargs): + captured_urls.append(kwargs["token_url"]) + return {"access_token": "tok", "expires_in": 3600} + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.side_effect = fake_fetch_token + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test") # no tenant_subdomain → None + + assert captured_urls[0] == token_url + + def test_tokens_cached_independently_per_tenant(self): + """Provider and subscriber tokens are cached under separate keys.""" + token_url = "http://prov.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test") # provider (None) + transport.get("/test", tenant_subdomain="sub") # subscriber + + assert None in transport._oauth_cache + assert "sub" in transport._oauth_cache + assert mock_oauth.fetch_token.call_count == 2 + + def test_subscriber_token_reused_on_second_call(self): + """Subscriber token is cached and not re-fetched on a second call.""" + token_url = "http://prov.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test", tenant_subdomain="sub") + transport.get("/test", tenant_subdomain="sub") + + assert mock_oauth.fetch_token.call_count == 1 + + # ── HTTP methods ────────────────────────────────────────────────────────────── @@ -242,8 +351,8 @@ def test_server_error_raises_http_error(self): class TestClose: - def test_close_clears_oauth_session(self): - """close() clears the OAuth session.""" + def test_close_clears_all_oauth_sessions(self): + """close() closes all cached OAuth sessions.""" with patch( "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" ) as MockOAuth, patch( @@ -254,12 +363,15 @@ def test_close_clears_oauth_session(self): mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} mock_oauth.request.return_value = _mock_response(200, {}) - transport = HttpTransport(_config()) - transport.get("/test") + token_url = "http://prov.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) + transport = HttpTransport(cfg) + transport.get("/test") # provider + transport.get("/test", tenant_subdomain="sub") # subscriber transport.close() - mock_oauth.close.assert_called_once() - assert transport._oauth is None + assert mock_oauth.close.call_count == 2 + assert len(transport._oauth_cache) == 0 def test_close_clears_plain_session(self): """close() clears the plain session in no-auth mode.""" diff --git a/tests/destination/integration/test_destination_bdd.py b/tests/destination/integration/test_destination_bdd.py index 089ab9e9..3670c42f 100644 --- a/tests/destination/integration/test_destination_bdd.py +++ b/tests/destination/integration/test_destination_bdd.py @@ -1615,10 +1615,15 @@ def send_get_request(context, path): import requests try: context.http_response = context.http_client.request("GET", path) - except requests.exceptions.ConnectionError as e: + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: pytest.skip(f"External endpoint unreachable — skipping: {e}") - except requests.exceptions.Timeout as e: - pytest.skip(f"External endpoint timed out — skipping: {e}") + + # skip if the echo service itself returned an error + if not context.http_response.ok: + pytest.skip( + f"External endpoint returned {context.http_response.status_code}: " + f"{context.http_response.text[:200]}" + ) @then("the response contains an Authorization header") From c9888b79963cbea62ac851ca1a1a61577e237736 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Thu, 9 Jul 2026 16:59:32 -0300 Subject: [PATCH 2/6] style(agent-memory): apply ruff formatting --- src/sap_cloud_sdk/agent_memory/_http_transport.py | 8 ++++++-- src/sap_cloud_sdk/agent_memory/client.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/_http_transport.py b/src/sap_cloud_sdk/agent_memory/_http_transport.py index 6d6296fc..9ec4daad 100644 --- a/src/sap_cloud_sdk/agent_memory/_http_transport.py +++ b/src/sap_cloud_sdk/agent_memory/_http_transport.py @@ -77,7 +77,9 @@ def get( AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("GET", path, params=params, tenant_subdomain=tenant_subdomain) + return self._request( + "GET", path, params=params, tenant_subdomain=tenant_subdomain + ) def post( self, @@ -123,7 +125,9 @@ def patch( AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("PATCH", path, json=json, tenant_subdomain=tenant_subdomain) + return self._request( + "PATCH", path, json=json, tenant_subdomain=tenant_subdomain + ) def delete(self, path: str, *, tenant_subdomain: Optional[str] = None) -> None: """Perform a DELETE request. diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index de696f9f..2248d644 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -147,7 +147,9 @@ def add_memory( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post(MEMORIES, json=payload, tenant_subdomain=tenant_subdomain) + data = self._transport.post( + MEMORIES, json=payload, tenant_subdomain=tenant_subdomain + ) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MEMORY) From db9ba9b53401516ea3f481e64657fcbf9d2f9f69 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Tue, 14 Jul 2026 17:38:39 -0300 Subject: [PATCH 3/6] feat(agent-memory): configure access_strategy and tenant at client level - create_client() and AgentMemoryClient.__init__ accept access_strategy (default SUBSCRIBER_ONLY) and tenant as constructor params - _resolve_tenant is now an instance method; per-call params override instance defaults - All method signatures change to Optional[AccessStrategy] = None (sentinel: use instance default) - user-guide: add client-level configuration section and PROVIDER_ONLY isolation warning - tests: update TestAccessStrategy and TestCreateClient for instance method --- src/sap_cloud_sdk/agent_memory/__init__.py | 30 +++- src/sap_cloud_sdk/agent_memory/client.py | 154 +++++++++++++------ src/sap_cloud_sdk/agent_memory/user-guide.md | 43 +++++- tests/agent_memory/integration/conftest.py | 10 +- tests/agent_memory/unit/test_client.py | 118 ++++++++++---- 5 files changed, 266 insertions(+), 89 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 2684145d..8d5b4099 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -5,9 +5,13 @@ Usage:: - from sap_cloud_sdk.agent_memory import create_client + from sap_cloud_sdk.agent_memory import create_client, AccessStrategy - client = create_client() + # Subscriber tenant — strategy and tenant set once, inherited by all calls + client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="my-tenant-subdomain", + ) memories = client.list_memories(agent_id="my-agent", invoker_id="user-123") """ @@ -34,7 +38,12 @@ from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition -def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryClient: +def create_client( + *, + config: Optional[AgentMemoryConfig] = None, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, +) -> AgentMemoryClient: """Create an :class:`AgentMemoryClient` with automatic credential detection. Args: @@ -42,17 +51,28 @@ def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryC loaded from the mounted volume at ``/etc/secrets/appfnd/hana-agent-memory/default/`` or from ``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables. + access_strategy: Default tenant access strategy for all client operations. + Defaults to ``SUBSCRIBER_ONLY``. Individual method calls may override + this value. + tenant: Default subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER_ONLY``. Individual method calls may + override this value. Returns: A ready-to-use :class:`AgentMemoryClient`. Raises: - AgentMemoryConfigError: If configuration is missing or invalid. + AgentMemoryConfigError: If configuration is missing, invalid, or + ``access_strategy=SUBSCRIBER_ONLY`` is used without a ``tenant``. """ try: resolved_config = config if config is not None else _load_config_from_env() transport = HttpTransport(resolved_config) - return AgentMemoryClient(transport) + return AgentMemoryClient( + transport, + access_strategy=access_strategy, + tenant=tenant, + ) except AgentMemoryConfigError: raise except Exception as exc: diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index 2248d644..53b0dcdb 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -75,10 +75,22 @@ class AgentMemoryClient: Args: transport: HTTP transport layer (injected by ``create_client``). + access_strategy: Default tenant access strategy for all operations. + Defaults to ``SUBSCRIBER_ONLY``. Can be overridden per method call. + tenant: Default subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER_ONLY``. Can be overridden per method call. """ - def __init__(self, transport: HttpTransport) -> None: + def __init__( + self, + transport: HttpTransport, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + tenant: Optional[str] = None, + ) -> None: self._transport = transport + self._default_access_strategy = access_strategy + self._default_tenant = tenant def close(self) -> None: """Close the underlying HTTP session and release resources.""" @@ -90,22 +102,38 @@ def __enter__(self) -> AgentMemoryClient: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() - @staticmethod def _resolve_tenant( - access_strategy: AccessStrategy, tenant: Optional[str] + self, + access_strategy: Optional[AccessStrategy], + tenant: Optional[str], ) -> Optional[str]: """Return the tenant subdomain to use for token derivation. + Per-call parameters take precedence over instance defaults. + Raises: - AgentMemoryValidationError: If ``SUBSCRIBER_ONLY`` is requested but no tenant is given. + AgentMemoryValidationError: If the effective strategy is ``SUBSCRIBER_ONLY`` + but no tenant is available. """ - if access_strategy is AccessStrategy.SUBSCRIBER_ONLY: - if not tenant: - raise AgentMemoryValidationError( - "tenant is required when access_strategy=SUBSCRIBER_ONLY" - ) - return tenant - return None # PROVIDER_ONLY + effective_strategy = ( + access_strategy + if access_strategy is not None + else self._default_access_strategy + ) + effective_tenant = tenant if tenant is not None else self._default_tenant + + if ( + effective_strategy is AccessStrategy.SUBSCRIBER_ONLY + and not effective_tenant + ): + raise AgentMemoryValidationError( + "tenant is required when access_strategy=SUBSCRIBER_ONLY" + ) + return ( + effective_tenant + if effective_strategy is AccessStrategy.SUBSCRIBER_ONLY + else None + ) # ── Memory operations ────────────────────────────────────────────────────── @@ -117,7 +145,7 @@ def add_memory( content: str, *, metadata: Optional[dict[str, Any]] = None, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> Memory: """Create a new memory entry. @@ -127,8 +155,10 @@ def add_memory( invoker_id: Identifier of the user or invoker. content: The memory text content. metadata: Optional metadata dict (Map type in OData). - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: The created :class:`Memory`. @@ -157,15 +187,17 @@ def get_memory( self, memory_id: str, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> Memory: """Retrieve a memory by ID. Args: memory_id: The memory identifier (UUID). - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: The :class:`Memory`. @@ -190,7 +222,7 @@ def update_memory( *, content: Optional[str] = None, metadata: Optional[dict[str, Any]] = None, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> None: """Update a memory's content and/or metadata. @@ -199,8 +231,10 @@ def update_memory( memory_id: The memory identifier (UUID). content: New content to set. metadata: New metadata dict to set. - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. @@ -228,15 +262,17 @@ def delete_memory( self, memory_id: str, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> None: """Delete a memory permanently. Args: memory_id: The memory identifier (UUID). - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. @@ -259,7 +295,7 @@ def list_memories( filters: Optional[list[FilterDefinition]] = None, limit: int = 50, offset: int = 0, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> list[Memory]: """List memories, optionally filtered by agent and/or invoker. @@ -274,8 +310,10 @@ def list_memories( key-value structured search is not supported. limit: Maximum number of memories to return. Default is ``50``. offset: Number of memories to skip (for pagination). Default is ``0``. - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: List of :class:`Memory` objects. @@ -313,7 +351,7 @@ def count_memories( agent_id: Optional[str] = None, invoker_id: Optional[str] = None, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> int: """Count memories matching the given filters. @@ -321,8 +359,10 @@ def count_memories( Args: agent_id: Filter by agent identifier. invoker_id: Filter by invoker/user identifier. - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: Total number of matching memories. @@ -352,7 +392,7 @@ def search_memories( threshold: float = 0.6, limit: int = 10, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> list[SearchResult]: """Perform a semantic (vector) search over stored memories. @@ -363,8 +403,10 @@ def search_memories( query: Natural-language search query (5–5000 characters). threshold: Minimum cosine similarity score (0.0–1.0). Default ``0.6``. limit: Maximum number of results (1–50). Default is ``10``. - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: List of :class:`SearchResult` objects. @@ -410,7 +452,7 @@ def add_message( content: str, *, metadata: Optional[dict[str, Any]] = None, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> Message: """Create a new message. @@ -425,8 +467,10 @@ def add_message( role: Author role (USER, ASSISTANT, SYSTEM, TOOL). content: The message text content. metadata: Optional metadata dict. - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: The created :class:`Message`. @@ -462,15 +506,17 @@ def get_message( self, message_id: str, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> Message: """Retrieve a message by ID. Args: message_id: The message identifier (UUID). - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: The :class:`Message`. @@ -493,15 +539,17 @@ def delete_message( self, message_id: str, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> None: """Delete a message permanently. Args: message_id: The message identifier (UUID). - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Raises: AgentMemoryNotFoundError: If no message with the given ID exists. @@ -526,7 +574,7 @@ def list_messages( filters: Optional[list[FilterDefinition]] = None, limit: int = 50, offset: int = 0, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> list[Message]: """List messages, optionally filtered by agent, invoker, group, and role. @@ -543,8 +591,10 @@ def list_messages( key-value structured search is not supported. limit: Maximum number of messages to return. Default is ``50``. offset: Number of messages to skip (for pagination). Default is ``0``. - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: List of :class:`Message` objects. @@ -584,14 +634,16 @@ def list_messages( def get_retention_config( self, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> RetentionConfig: """Retrieve the data retention configuration (singleton). Args: - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Returns: The current :class:`RetentionConfig`. @@ -611,7 +663,7 @@ def update_retention_config( message_days: Optional[int] = None, memory_days: Optional[int] = None, usage_log_days: Optional[int] = None, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: Optional[AccessStrategy] = None, tenant: Optional[str] = None, ) -> None: """Update the data retention configuration. @@ -624,8 +676,10 @@ def update_retention_config( message_days: How long to keep messages (days). memory_days: How long to keep memories without access (days). usage_log_days: How long to keep access and search logs (days). - access_strategy: Tenant access strategy. Defaults to ``SUBSCRIBER_ONLY``. - tenant: Subscriber tenant subdomain. Required when ``access_strategy=SUBSCRIBER_ONLY``. + access_strategy: Tenant access strategy. Overrides the client default when + provided. Falls back to the default set on :func:`create_client`. + tenant: Subscriber tenant subdomain. Overrides the client default when + provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. Raises: AgentMemoryValidationError: If no fields are provided, any provided value is diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index e352bf0d..053e09d6 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -24,8 +24,9 @@ plain text, and the service makes it searchable by meaning. - [`invoker_id`](#invoker_id) - [Multitenancy](#multitenancy) - [AccessStrategy](#accessstrategy) - - [SUBSCRIBER_ONLY (default)](#subscriber_only-default) - - [PROVIDER_ONLY](#provider_only) + - [Configuring at client level](#configuring-at-client-level) + - [SUBSCRIBER\_ONLY (default)](#subscriber_only-default) + - [PROVIDER\_ONLY](#provider_only) - [Semantic Search: A Brief Primer](#semantic-search-a-brief-primer) - [Memories](#memories) - [Create a Memory](#create-a-memory) @@ -61,6 +62,10 @@ plain text, and the service makes it searchable by meaning. - [`AgentMemoryNotFoundError` when fetching a resource](#agentmemorynotfounderror-when-fetching-a-resource) - [`AgentMemoryHttpError` with status 401](#agentmemoryhttperror-with-status-401) - [Configuration](#configuration) + - [Service Binding](#service-binding) + - [Mounted Secrets (Kubernetes)](#mounted-secrets-kubernetes) + - [Environment Variables](#environment-variables) + - [UAA JSON Schema](#uaa-json-schema) ## Installation @@ -176,6 +181,36 @@ from sap_cloud_sdk.agent_memory import AccessStrategy | `SUBSCRIBER_ONLY` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | | `PROVIDER_ONLY` | Reads and writes against the provider tenant. No `tenant` needed. | +### Configuring at client level + +Pass `access_strategy` and `tenant` to `create_client()` to set defaults for the entire +client instance. Every method call then inherits them, so you do not need to repeat them +on each operation. + +```python +from sap_cloud_sdk.agent_memory import create_client, AccessStrategy + +# Tenant set once — all calls below use it automatically +client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="acme-corp", +) + +memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") +count = client.count_memories(agent_id="hr-assistant") +``` + +A per-call value overrides the client default for that single call: + +```python +# All calls use SUBSCRIBER_ONLY / "acme-corp" except this one +provider_memories = client.list_memories( + agent_id="hr-assistant", + invoker_id="user-42", + access_strategy=AccessStrategy.PROVIDER_ONLY, # overrides for this call only +) +``` + ### SUBSCRIBER_ONLY (default) Pass the subscriber tenant subdomain via the `tenant` argument. Omitting `tenant` when @@ -207,6 +242,10 @@ memories = client.list_memories( ) ``` +> [!WARNING] +> `PROVIDER_ONLY` provides **no tenant isolation** — the provider token grants access to data across all subscriber tenants. Only use this strategy for provider-owned operations +> (e.g., admin tasks, shared datasets). Never use it to serve subscriber-specific data. + > [!NOTE] > The `_FIRST` fallback strategies (`SUBSCRIBER_FIRST`, `PROVIDER_FIRST`) are not yet > supported and must be evaluated for silent cross-tenant access risks before being diff --git a/tests/agent_memory/integration/conftest.py b/tests/agent_memory/integration/conftest.py index 1918e633..0eecd02b 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -19,20 +19,24 @@ import pytest from dotenv import load_dotenv -from sap_cloud_sdk.agent_memory import create_client +from sap_cloud_sdk.agent_memory import AccessStrategy, create_client from sap_cloud_sdk.agent_memory.client import AgentMemoryClient from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @pytest.fixture(scope="session") def agent_memory_client() -> AgentMemoryClient: - """Create a real AgentMemoryClient from environment variables.""" + """Create a real AgentMemoryClient from environment variables. + + Uses PROVIDER_ONLY as the default strategy — individual BDD steps override + this per-call to exercise both PROVIDER_ONLY and SUBSCRIBER_ONLY scenarios. + """ env_file = Path(__file__).parents[3] / ".env_integration_tests" if env_file.exists(): load_dotenv(env_file, override=True) try: - return create_client() + return create_client(access_strategy=AccessStrategy.PROVIDER_ONLY) except AgentMemoryConfigError as e: pytest.skip(f"Agent Memory credentials not configured — skipping integration tests: {e}") except Exception as e: diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index 7961fe3d..515b0af0 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -24,9 +24,9 @@ from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError def _make_client() -> tuple[AgentMemoryClient, MagicMock]: - """Return an AgentMemoryClient with a mocked transport layer.""" + """Return an AgentMemoryClient with a mocked transport and PROVIDER_ONLY default.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) return client, transport @@ -35,13 +35,28 @@ def _make_client() -> tuple[AgentMemoryClient, MagicMock]: class TestCreateClient: - def test_uses_provided_config(self): - """Factory accepts an explicit config object.""" + def test_uses_provided_config_with_provider_strategy(self): + """Factory accepts an explicit config and PROVIDER_ONLY strategy.""" config = AgentMemoryConfig(base_url="http://localhost:3000") with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client(config=config) + client = create_client(config=config, access_strategy=AccessStrategy.PROVIDER_ONLY) assert isinstance(client, AgentMemoryClient) + assert client._default_access_strategy is AccessStrategy.PROVIDER_ONLY + assert client._default_tenant is None + + def test_uses_provided_config_with_subscriber_strategy(self): + """Factory stores subscriber strategy and tenant on the client.""" + config = AgentMemoryConfig(base_url="http://localhost:3000") + with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: + MockTransport.return_value = MagicMock(spec=HttpTransport) + client = create_client( + config=config, + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="acme-corp", + ) + assert client._default_access_strategy is AccessStrategy.SUBSCRIBER_ONLY + assert client._default_tenant == "acme-corp" def test_reads_env_when_no_config_provided(self, monkeypatch): """Factory falls back to environment variables when no config given.""" @@ -54,7 +69,7 @@ def test_reads_env_when_no_config_provided(self, monkeypatch): })) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client() + client = create_client(access_strategy=AccessStrategy.PROVIDER_ONLY) assert isinstance(client, AgentMemoryClient) @@ -62,35 +77,80 @@ def test_reads_env_when_no_config_provided(self, monkeypatch): class TestAccessStrategy: - def test_subscriber_only_with_tenant_resolves_to_subdomain(self): - """SUBSCRIBER_ONLY with a tenant returns the tenant subdomain.""" - subdomain = AgentMemoryClient._resolve_tenant( - AccessStrategy.SUBSCRIBER_ONLY, "my-tenant" - ) - assert subdomain == "my-tenant" + def _subscriber_client(self, tenant="default-sub"): + """Helper: client with SUBSCRIBER_ONLY default.""" + transport = MagicMock(spec=HttpTransport) + return AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant=tenant) - def test_subscriber_only_without_tenant_raises(self): - """SUBSCRIBER_ONLY without a tenant raises AgentMemoryValidationError.""" + # ── _resolve_tenant instance method ─────────────────────────────────────── + + def test_subscriber_default_with_no_override_returns_default_tenant(self): + """No per-call params → instance default tenant is used.""" + client = self._subscriber_client("default-sub") + assert client._resolve_tenant(None, None) == "default-sub" + + def test_subscriber_default_no_tenant_raises_at_call_time(self): + """SUBSCRIBER_ONLY default with no tenant raises at first call.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY) with pytest.raises(AgentMemoryValidationError, match="tenant"): - AgentMemoryClient._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, None) + client._resolve_tenant(None, None) - def test_subscriber_only_with_empty_tenant_raises(self): - """SUBSCRIBER_ONLY with an empty tenant string raises AgentMemoryValidationError.""" + def test_per_call_tenant_overrides_default(self): + """Per-call tenant overrides the instance default tenant.""" + client = self._subscriber_client("default-sub") + assert client._resolve_tenant(None, "override-sub") == "override-sub" + + def test_per_call_provider_overrides_subscriber_default(self): + """Per-call PROVIDER_ONLY overrides a SUBSCRIBER_ONLY instance default.""" + client = self._subscriber_client("default-sub") + assert client._resolve_tenant(AccessStrategy.PROVIDER_ONLY, None) is None + + def test_provider_default_resolves_to_none(self): + """PROVIDER_ONLY default returns None.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + assert client._resolve_tenant(None, None) is None + + def test_per_call_subscriber_overrides_provider_default(self): + """Per-call SUBSCRIBER_ONLY overrides a PROVIDER_ONLY instance default.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + assert client._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, "sub") == "sub" + + def test_per_call_subscriber_without_tenant_raises(self): + """Per-call SUBSCRIBER_ONLY with no tenant raises even when default is PROVIDER_ONLY.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) with pytest.raises(AgentMemoryValidationError, match="tenant"): - AgentMemoryClient._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, "") + client._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, None) + + # ── Client-level defaults flow through to transport ─────────────────────── + + def test_client_default_subscriber_passes_tenant_without_per_call_params(self): + """Client with SUBSCRIBER_ONLY default uses default tenant on transport call.""" + transport = MagicMock(spec=HttpTransport) + transport.post.return_value = {"id": "m1", "agentID": "a", "invokerID": "u", "content": "x"} + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="default-sub") + + client.add_memory("a", "u", "x") + + assert transport.post.call_args[1]["tenant_subdomain"] == "default-sub" + + def test_per_call_tenant_overrides_default_on_transport(self): + """Per-call tenant overrides client default when forwarded to transport.""" + transport = MagicMock(spec=HttpTransport) + transport.post.return_value = {"id": "m1", "agentID": "a", "invokerID": "u", "content": "x"} + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="default-sub") + + client.add_memory("a", "u", "x", tenant="override-sub") - def test_provider_only_without_tenant_resolves_to_none(self): - """PROVIDER_ONLY without a tenant resolves to None (no subdomain substitution).""" - subdomain = AgentMemoryClient._resolve_tenant(AccessStrategy.PROVIDER_ONLY, None) - assert subdomain is None + assert transport.post.call_args[1]["tenant_subdomain"] == "override-sub" - def test_provider_only_ignores_tenant(self): - """PROVIDER_ONLY ignores any tenant value and always returns None.""" - subdomain = AgentMemoryClient._resolve_tenant(AccessStrategy.PROVIDER_ONLY, "ignored") - assert subdomain is None + # ── Per-call explicit params (existing behaviour) ───────────────────────── def test_add_memory_subscriber_only_passes_tenant_to_transport(self): - """add_memory with SUBSCRIBER_ONLY passes tenant_subdomain to transport.""" + """add_memory with per-call SUBSCRIBER_ONLY passes tenant_subdomain to transport.""" client, mock_transport = _make_client() mock_transport.post.return_value = { "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", @@ -116,7 +176,7 @@ def test_add_memory_provider_only_passes_none_to_transport(self): assert mock_transport.post.call_args[1]["tenant_subdomain"] is None def test_add_memory_subscriber_only_without_tenant_raises(self): - """add_memory with SUBSCRIBER_ONLY and no tenant raises before transport call.""" + """add_memory with per-call SUBSCRIBER_ONLY and no tenant raises before transport call.""" client, mock_transport = _make_client() with pytest.raises(AgentMemoryValidationError, match="tenant"): @@ -850,7 +910,7 @@ def test_close_delegates_to_transport(self): def test_context_manager_closes_on_exit(self): """Using the client as a context manager closes it on __exit__.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) with client: pass From 7e27312fe63b4a40ab41e61ce03f08d252b75d34 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Tue, 14 Jul 2026 17:43:51 -0300 Subject: [PATCH 4/6] docs(agent-memory): update access strategy descriptions and warnings for tenant isolation --- src/sap_cloud_sdk/agent_memory/user-guide.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 053e09d6..46c19107 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -176,10 +176,10 @@ that your application serves. You control this behaviour with the `access_strate from sap_cloud_sdk.agent_memory import AccessStrategy ``` -| Value | Description | -| --------------------------- | ------------------------------------------------------------------ | -| `SUBSCRIBER_ONLY` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | -| `PROVIDER_ONLY` | Reads and writes against the provider tenant. No `tenant` needed. | +| Value | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `SUBSCRIBER_ONLY` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | +| `PROVIDER_ONLY` | Reads and writes against the provider tenant. No `tenant` needed. Caution: this provides no tenant isolation. | ### Configuring at client level @@ -243,13 +243,7 @@ memories = client.list_memories( ``` > [!WARNING] -> `PROVIDER_ONLY` provides **no tenant isolation** — the provider token grants access to data across all subscriber tenants. Only use this strategy for provider-owned operations -> (e.g., admin tasks, shared datasets). Never use it to serve subscriber-specific data. - -> [!NOTE] -> The `_FIRST` fallback strategies (`SUBSCRIBER_FIRST`, `PROVIDER_FIRST`) are not yet -> supported and must be evaluated for silent cross-tenant access risks before being -> introduced. +> `PROVIDER_ONLY` provides **no tenant isolation** — the provider token grants access to data across all subscriber tenants Only use this strategy for provider-owned operations (e.g., admin tasks, shared datasets). Never use it to serve subscriber-specific data. ## Semantic Search: A Brief Primer From ca348f04ee48d2a585abb6fe8efc7f0df8f854cc Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Wed, 15 Jul 2026 18:01:17 -0300 Subject: [PATCH 5/6] refactor(agent-memory): rename AccessStrategy values and add PROVIDER warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUBSCRIBER_ONLY → SUBSCRIBER, PROVIDER_ONLY → PROVIDER across all agent_memory source, tests, feature files, and user guide - Add logger.warning() when AccessStrategy.PROVIDER is used, making the no-isolation behaviour explicit at runtime --- src/sap_cloud_sdk/agent_memory/__init__.py | 10 +- src/sap_cloud_sdk/agent_memory/_models.py | 4 +- src/sap_cloud_sdk/agent_memory/client.py | 77 +++--- src/sap_cloud_sdk/agent_memory/config.py | 2 +- src/sap_cloud_sdk/agent_memory/user-guide.md | 24 +- .../integration/agentmemory.feature | 20 +- tests/agent_memory/integration/conftest.py | 8 +- .../integration/test_agentmemory_bdd.py | 24 +- tests/agent_memory/unit/test_client.py | 244 +++++++++--------- 9 files changed, 209 insertions(+), 204 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 8d5b4099..5c520768 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -9,7 +9,7 @@ # Subscriber tenant — strategy and tenant set once, inherited by all calls client = create_client( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="my-tenant-subdomain", ) memories = client.list_memories(agent_id="my-agent", invoker_id="user-123") @@ -41,7 +41,7 @@ def create_client( *, config: Optional[AgentMemoryConfig] = None, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, tenant: Optional[str] = None, ) -> AgentMemoryClient: """Create an :class:`AgentMemoryClient` with automatic credential detection. @@ -52,10 +52,10 @@ def create_client( ``/etc/secrets/appfnd/hana-agent-memory/default/`` or from ``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables. access_strategy: Default tenant access strategy for all client operations. - Defaults to ``SUBSCRIBER_ONLY``. Individual method calls may override + Defaults to ``SUBSCRIBER``. Individual method calls may override this value. tenant: Default subscriber tenant subdomain. Required when - ``access_strategy=SUBSCRIBER_ONLY``. Individual method calls may + ``access_strategy=SUBSCRIBER``. Individual method calls may override this value. Returns: @@ -63,7 +63,7 @@ def create_client( Raises: AgentMemoryConfigError: If configuration is missing, invalid, or - ``access_strategy=SUBSCRIBER_ONLY`` is used without a ``tenant``. + ``access_strategy=SUBSCRIBER`` is used without a ``tenant``. """ try: resolved_config = config if config is not None else _load_config_from_env() diff --git a/src/sap_cloud_sdk/agent_memory/_models.py b/src/sap_cloud_sdk/agent_memory/_models.py index 604ed827..0d9f0862 100644 --- a/src/sap_cloud_sdk/agent_memory/_models.py +++ b/src/sap_cloud_sdk/agent_memory/_models.py @@ -18,8 +18,8 @@ class AccessStrategy(str, Enum): """Access strategy for tenant-scoped Agent Memory operations.""" - SUBSCRIBER_ONLY = "SUBSCRIBER_ONLY" - PROVIDER_ONLY = "PROVIDER_ONLY" + SUBSCRIBER = "SUBSCRIBER" + PROVIDER = "PROVIDER" class MessageRole(str, Enum): diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index 53b0dcdb..db116ba3 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -9,6 +9,7 @@ from __future__ import annotations +import logging from typing import Any, Optional from sap_cloud_sdk.agent_memory._endpoints import ( @@ -38,6 +39,8 @@ ) from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +logger = logging.getLogger(__name__) + def _require_non_empty(**fields: str) -> None: """Raise AgentMemoryValidationError if any named field is an empty string.""" @@ -76,16 +79,16 @@ class AgentMemoryClient: Args: transport: HTTP transport layer (injected by ``create_client``). access_strategy: Default tenant access strategy for all operations. - Defaults to ``SUBSCRIBER_ONLY``. Can be overridden per method call. + Defaults to ``SUBSCRIBER``. Can be overridden per method call. tenant: Default subscriber tenant subdomain. Required when - ``access_strategy=SUBSCRIBER_ONLY``. Can be overridden per method call. + ``access_strategy=SUBSCRIBER``. Can be overridden per method call. """ def __init__( self, transport: HttpTransport, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, tenant: Optional[str] = None, ) -> None: self._transport = transport @@ -112,7 +115,7 @@ def _resolve_tenant( Per-call parameters take precedence over instance defaults. Raises: - AgentMemoryValidationError: If the effective strategy is ``SUBSCRIBER_ONLY`` + AgentMemoryValidationError: If the effective strategy is ``SUBSCRIBER`` but no tenant is available. """ effective_strategy = ( @@ -122,16 +125,18 @@ def _resolve_tenant( ) effective_tenant = tenant if tenant is not None else self._default_tenant - if ( - effective_strategy is AccessStrategy.SUBSCRIBER_ONLY - and not effective_tenant - ): + if effective_strategy is AccessStrategy.SUBSCRIBER and not effective_tenant: raise AgentMemoryValidationError( - "tenant is required when access_strategy=SUBSCRIBER_ONLY" + "tenant is required when access_strategy=SUBSCRIBER" + ) + if effective_strategy is not AccessStrategy.SUBSCRIBER: + logger.warning( + "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " + "Only use this strategy for provider-owned operations." ) return ( effective_tenant - if effective_strategy is AccessStrategy.SUBSCRIBER_ONLY + if effective_strategy is AccessStrategy.SUBSCRIBER else None ) @@ -158,14 +163,14 @@ def add_memory( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: The created :class:`Memory`. Raises: AgentMemoryValidationError: If any required field is empty or tenant is missing - for ``SUBSCRIBER_ONLY``. + for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, content=content) @@ -197,7 +202,7 @@ def get_memory( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: The :class:`Memory`. @@ -205,7 +210,7 @@ def get_memory( Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. AgentMemoryValidationError: If ``memory_id`` is empty or tenant is missing - for ``SUBSCRIBER_ONLY``. + for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) @@ -234,12 +239,12 @@ def update_memory( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. AgentMemoryValidationError: If ``memory_id`` is empty, no fields are provided, - or tenant is missing for ``SUBSCRIBER_ONLY``. + or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) @@ -272,12 +277,12 @@ def delete_memory( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. AgentMemoryValidationError: If ``memory_id`` is empty or tenant is missing - for ``SUBSCRIBER_ONLY``. + for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) @@ -313,14 +318,14 @@ def list_memories( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: List of :class:`Memory` objects. Raises: AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, a filter - clause is invalid, or tenant is missing for ``SUBSCRIBER_ONLY``. + clause is invalid, or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ if limit < 1: @@ -362,13 +367,13 @@ def count_memories( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: Total number of matching memories. Raises: - AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER_ONLY``. + AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ tenant_subdomain = self._resolve_tenant(access_strategy, tenant) @@ -406,7 +411,7 @@ def search_memories( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: List of :class:`SearchResult` objects. @@ -414,7 +419,7 @@ def search_memories( Raises: AgentMemoryValidationError: If required fields are empty, parameters are out of range (``query`` must be 5–5000 chars, ``threshold`` 0.0–1.0, - ``limit`` 1–50), or tenant is missing for ``SUBSCRIBER_ONLY``. + ``limit`` 1–50), or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, query=query) @@ -470,14 +475,14 @@ def add_message( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: The created :class:`Message`. Raises: AgentMemoryValidationError: If any required field is empty or tenant is missing - for ``SUBSCRIBER_ONLY``. + for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty( @@ -516,7 +521,7 @@ def get_message( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: The :class:`Message`. @@ -524,7 +529,7 @@ def get_message( Raises: AgentMemoryNotFoundError: If no message with the given ID exists. AgentMemoryValidationError: If ``message_id`` is empty or tenant is missing - for ``SUBSCRIBER_ONLY``. + for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) @@ -549,12 +554,12 @@ def delete_message( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Raises: AgentMemoryNotFoundError: If no message with the given ID exists. AgentMemoryValidationError: If ``message_id`` is empty or tenant is missing - for ``SUBSCRIBER_ONLY``. + for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) @@ -594,14 +599,14 @@ def list_messages( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: List of :class:`Message` objects. Raises: AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, a filter - clause is invalid, or tenant is missing for ``SUBSCRIBER_ONLY``. + clause is invalid, or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ if limit < 1: @@ -643,13 +648,13 @@ def get_retention_config( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Returns: The current :class:`RetentionConfig`. Raises: - AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER_ONLY``. + AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ tenant_subdomain = self._resolve_tenant(access_strategy, tenant) @@ -679,11 +684,11 @@ def update_retention_config( access_strategy: Tenant access strategy. Overrides the client default when provided. Falls back to the default set on :func:`create_client`. tenant: Subscriber tenant subdomain. Overrides the client default when - provided. Required (at call or client level) when strategy is ``SUBSCRIBER_ONLY``. + provided. Required (at call or client level) when strategy is ``SUBSCRIBER``. Raises: AgentMemoryValidationError: If no fields are provided, any provided value is - negative, or tenant is missing for ``SUBSCRIBER_ONLY``. + negative, or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ if message_days is None and memory_days is None and usage_log_days is None: diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index a32afb70..7fe97dfb 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -36,7 +36,7 @@ class AgentMemoryConfig: client_id: The OAuth2 client ID. Optional. client_secret: The OAuth2 client secret. Optional. identityzone: The provider tenant identity zone subdomain. Required when using - ``SUBSCRIBER_ONLY`` access strategy so the subscriber token URL + ``SUBSCRIBER`` access strategy so the subscriber token URL can be derived by replacing this value in ``token_url``. timeout: Timeout in seconds for HTTP requests. Default is 30.0. diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 46a1e531..8d78423b 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -184,8 +184,8 @@ from sap_cloud_sdk.agent_memory import AccessStrategy | Value | Description | | --------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `SUBSCRIBER_ONLY` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | -| `PROVIDER_ONLY` | Reads and writes against the provider tenant. No `tenant` needed. Caution: this provides no tenant isolation. | +| `SUBSCRIBER` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | +| `PROVIDER` | Reads and writes against the provider tenant. No `tenant` needed. Caution: this provides no tenant isolation. | ### Configuring at client level @@ -198,7 +198,7 @@ from sap_cloud_sdk.agent_memory import create_client, AccessStrategy # Tenant set once — all calls below use it automatically client = create_client( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", ) @@ -209,24 +209,24 @@ count = client.count_memories(agent_id="hr-assistant") A per-call value overrides the client default for that single call: ```python -# All calls use SUBSCRIBER_ONLY / "acme-corp" except this one +# All calls use SUBSCRIBER / "acme-corp" except this one provider_memories = client.list_memories( agent_id="hr-assistant", invoker_id="user-42", - access_strategy=AccessStrategy.PROVIDER_ONLY, # overrides for this call only + access_strategy=AccessStrategy.PROVIDER, # overrides for this call only ) ``` -### SUBSCRIBER_ONLY (default) +### SUBSCRIBER (default) Pass the subscriber tenant subdomain via the `tenant` argument. Omitting `tenant` when -the strategy is `SUBSCRIBER_ONLY` raises `AgentMemoryValidationError`. +the strategy is `SUBSCRIBER` raises `AgentMemoryValidationError`. ```python memories = client.list_memories( agent_id="hr-assistant", invoker_id="user-42", - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", # subscriber subdomain ) ``` @@ -236,7 +236,7 @@ in the configured `token_url` with the `tenant` value. This requires `identityzo be present in the service binding's UAA JSON (standard XSUAA field) or set explicitly in `AgentMemoryConfig`. -### PROVIDER_ONLY +### PROVIDER No `tenant` argument is needed. All calls use the provider token. @@ -244,12 +244,12 @@ No `tenant` argument is needed. All calls use the provider token. memories = client.list_memories( agent_id="hr-assistant", invoker_id="user-42", - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) ``` > [!WARNING] -> `PROVIDER_ONLY` provides **no tenant isolation** — the provider token grants access to data across all subscriber tenants Only use this strategy for provider-owned operations (e.g., admin tasks, shared datasets). Never use it to serve subscriber-specific data. +> `PROVIDER` provides **no tenant isolation** — the provider token grants access to data across all subscriber tenants Only use this strategy for provider-owned operations (e.g., admin tasks, shared datasets). Never use it to serve subscriber-specific data. ## Semantic Search: A Brief Primer @@ -591,7 +591,7 @@ See the [Content and metadata filtering](#content-and-metadata-filtering) note u | Enum | Values | | ---------------- | -------------------------------------------- | | `MessageRole` | `USER`, `ASSISTANT`, `SYSTEM`, `TOOL` | -| `AccessStrategy` | `SUBSCRIBER_ONLY` (default), `PROVIDER_ONLY` | +| `AccessStrategy` | `SUBSCRIBER` (default), `PROVIDER` | All models expose a `to_dict()` method that returns a plain dict for logging or forwarding. diff --git a/tests/agent_memory/integration/agentmemory.feature b/tests/agent_memory/integration/agentmemory.feature index 632cc954..7321c5de 100644 --- a/tests/agent_memory/integration/agentmemory.feature +++ b/tests/agent_memory/integration/agentmemory.feature @@ -94,7 +94,7 @@ Feature: Agent Memory Service Integration (v1 API) # ──── Memory CRUD ───────────────────────────────────────────────────────────── - Scenario: Create a new memory using SUBSCRIBER_ONLY access strategy + Scenario: Create a new memory using SUBSCRIBER access strategy Given I use the configured subscriber tenant When I create a memory with agent "test-agent" and invoker "test-user" and content "User prefers dark mode" Then the memory should have a non-empty id @@ -102,26 +102,26 @@ Feature: Agent Memory Service Integration (v1 API) And the memory should have invoker_id "test-user" And the memory should have content "User prefers dark mode" - Scenario: Get a memory using SUBSCRIBER_ONLY access strategy + Scenario: Get a memory using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a memory exists with agent "test-agent" and invoker "test-user" and content "Test memory" When I get the memory by id Then the returned memory should match the created memory - Scenario: Update memory content using SUBSCRIBER_ONLY access strategy + Scenario: Update memory content using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a memory exists with agent "test-agent" and invoker "test-user" and content "Original content" When I update the memory content to "Updated content" Then the memory should have content "Updated content" - Scenario: List memories using SUBSCRIBER_ONLY access strategy + Scenario: List memories using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a memory exists with agent "test-agent" and invoker "test-user" and content "Listed memory" When I list memories filtered by agent "test-agent" Then the result should contain at least one memory And the total count should be a positive number - Scenario: Delete a memory using SUBSCRIBER_ONLY access strategy + Scenario: Delete a memory using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a memory exists with agent "test-agent" and invoker "test-user" and content "To be deleted" When I delete the memory @@ -129,7 +129,7 @@ Feature: Agent Memory Service Integration (v1 API) # ──── Memory search ─────────────────────────────────────────────────────────── - Scenario: Search memories using SUBSCRIBER_ONLY access strategy + Scenario: Search memories using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a memory exists with agent "test-agent" and invoker "test-user" and content "The user loves dark mode and dark themes" When I search for memories with query "dark mode preference" @@ -138,21 +138,21 @@ Feature: Agent Memory Service Integration (v1 API) # ──── Message CRUD ──────────────────────────────────────────────────────────── - Scenario: Create and get a message using SUBSCRIBER_ONLY access strategy + Scenario: Create and get a message using SUBSCRIBER access strategy Given I use the configured subscriber tenant When I create a message with agent "test-agent" invoker "test-user" group "conv-1" role "USER" content "Hello!" Then the message should have a non-empty id And the message should have role "USER" And the message should have content "Hello!" - Scenario: List messages using SUBSCRIBER_ONLY access strategy + Scenario: List messages using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a message exists with agent "test-agent" invoker "test-user" group "conv-list" role "USER" content "Listed message" When I list messages filtered by agent "test-agent" and group "conv-list" Then the result should contain at least one message And the total count should be a positive number - Scenario: Delete a message using SUBSCRIBER_ONLY access strategy + Scenario: Delete a message using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a message exists with agent "test-agent" invoker "test-user" group "conv-del" role "USER" content "To be deleted" When I delete the message @@ -160,7 +160,7 @@ Feature: Agent Memory Service Integration (v1 API) # ──── Bulk / utility operations ─────────────────────────────────────────────── - Scenario: Count memories using SUBSCRIBER_ONLY access strategy + Scenario: Count memories using SUBSCRIBER access strategy Given I use the configured subscriber tenant And a memory exists with agent "test-agent" and invoker "test-user" and content "Count test memory" When I count memories for agent "test-agent" and invoker "test-user" diff --git a/tests/agent_memory/integration/conftest.py b/tests/agent_memory/integration/conftest.py index 0eecd02b..77e2760a 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -10,7 +10,7 @@ Multitenancy: CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT Subscriber tenant subdomain - Required for SUBSCRIBER_ONLY tests. When absent those tests are skipped. + Required for SUBSCRIBER tests. When absent those tests are skipped. """ import os @@ -28,15 +28,15 @@ def agent_memory_client() -> AgentMemoryClient: """Create a real AgentMemoryClient from environment variables. - Uses PROVIDER_ONLY as the default strategy — individual BDD steps override - this per-call to exercise both PROVIDER_ONLY and SUBSCRIBER_ONLY scenarios. + Uses PROVIDER as the default strategy — individual BDD steps override + this per-call to exercise both PROVIDER and SUBSCRIBER scenarios. """ env_file = Path(__file__).parents[3] / ".env_integration_tests" if env_file.exists(): load_dotenv(env_file, override=True) try: - return create_client(access_strategy=AccessStrategy.PROVIDER_ONLY) + return create_client(access_strategy=AccessStrategy.PROVIDER) except AgentMemoryConfigError as e: pytest.skip(f"Agent Memory credentials not configured — skipping integration tests: {e}") except Exception as e: diff --git a/tests/agent_memory/integration/test_agentmemory_bdd.py b/tests/agent_memory/integration/test_agentmemory_bdd.py index 16d77311..df71e438 100644 --- a/tests/agent_memory/integration/test_agentmemory_bdd.py +++ b/tests/agent_memory/integration/test_agentmemory_bdd.py @@ -97,52 +97,52 @@ def test_filter_messages_by_metadata(): # ── Subscriber scenarios ────────────────────────────────────────────────────── -@scenario("agentmemory.feature", "Create a new memory using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Create a new memory using SUBSCRIBER access strategy") def test_add_memory_subscriber(): pass -@scenario("agentmemory.feature", "Get a memory using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Get a memory using SUBSCRIBER access strategy") def test_get_memory_subscriber(): pass -@scenario("agentmemory.feature", "Update memory content using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Update memory content using SUBSCRIBER access strategy") def test_update_memory_subscriber(): pass -@scenario("agentmemory.feature", "List memories using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "List memories using SUBSCRIBER access strategy") def test_list_memories_subscriber(): pass -@scenario("agentmemory.feature", "Delete a memory using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Delete a memory using SUBSCRIBER access strategy") def test_delete_memory_subscriber(): pass -@scenario("agentmemory.feature", "Search memories using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Search memories using SUBSCRIBER access strategy") def test_search_memories_subscriber(): pass -@scenario("agentmemory.feature", "Create and get a message using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Create and get a message using SUBSCRIBER access strategy") def test_add_message_subscriber(): pass -@scenario("agentmemory.feature", "List messages using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "List messages using SUBSCRIBER access strategy") def test_list_messages_subscriber(): pass -@scenario("agentmemory.feature", "Delete a message using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Delete a message using SUBSCRIBER access strategy") def test_delete_message_subscriber(): pass -@scenario("agentmemory.feature", "Count memories using SUBSCRIBER_ONLY access strategy") +@scenario("agentmemory.feature", "Count memories using SUBSCRIBER access strategy") def test_count_memories_subscriber(): pass @@ -163,7 +163,7 @@ def test_filter_messages_by_metadata_subscriber(): @pytest.fixture def context(): return { - "access_strategy": AccessStrategy.PROVIDER_ONLY, + "access_strategy": AccessStrategy.PROVIDER, "tenant": None, } @@ -178,7 +178,7 @@ def configured_client(context, agent_memory_client): @given("I use the configured subscriber tenant") def use_configured_subscriber_tenant(context, subscriber_tenant): - context["access_strategy"] = AccessStrategy.SUBSCRIBER_ONLY + context["access_strategy"] = AccessStrategy.SUBSCRIBER context["tenant"] = subscriber_tenant diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index 515b0af0..102ba86c 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -24,9 +24,9 @@ from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError def _make_client() -> tuple[AgentMemoryClient, MagicMock]: - """Return an AgentMemoryClient with a mocked transport and PROVIDER_ONLY default.""" + """Return an AgentMemoryClient with a mocked transport and PROVIDER default.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) return client, transport @@ -36,13 +36,13 @@ def _make_client() -> tuple[AgentMemoryClient, MagicMock]: class TestCreateClient: def test_uses_provided_config_with_provider_strategy(self): - """Factory accepts an explicit config and PROVIDER_ONLY strategy.""" + """Factory accepts an explicit config and PROVIDER strategy.""" config = AgentMemoryConfig(base_url="http://localhost:3000") with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client(config=config, access_strategy=AccessStrategy.PROVIDER_ONLY) + client = create_client(config=config, access_strategy=AccessStrategy.PROVIDER) assert isinstance(client, AgentMemoryClient) - assert client._default_access_strategy is AccessStrategy.PROVIDER_ONLY + assert client._default_access_strategy is AccessStrategy.PROVIDER assert client._default_tenant is None def test_uses_provided_config_with_subscriber_strategy(self): @@ -52,10 +52,10 @@ def test_uses_provided_config_with_subscriber_strategy(self): MockTransport.return_value = MagicMock(spec=HttpTransport) client = create_client( config=config, - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", ) - assert client._default_access_strategy is AccessStrategy.SUBSCRIBER_ONLY + assert client._default_access_strategy is AccessStrategy.SUBSCRIBER assert client._default_tenant == "acme-corp" def test_reads_env_when_no_config_provided(self, monkeypatch): @@ -69,7 +69,7 @@ def test_reads_env_when_no_config_provided(self, monkeypatch): })) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client(access_strategy=AccessStrategy.PROVIDER_ONLY) + client = create_client(access_strategy=AccessStrategy.PROVIDER) assert isinstance(client, AgentMemoryClient) @@ -78,9 +78,9 @@ def test_reads_env_when_no_config_provided(self, monkeypatch): class TestAccessStrategy: def _subscriber_client(self, tenant="default-sub"): - """Helper: client with SUBSCRIBER_ONLY default.""" + """Helper: client with SUBSCRIBER default.""" transport = MagicMock(spec=HttpTransport) - return AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant=tenant) + return AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant=tenant) # ── _resolve_tenant instance method ─────────────────────────────────────── @@ -90,9 +90,9 @@ def test_subscriber_default_with_no_override_returns_default_tenant(self): assert client._resolve_tenant(None, None) == "default-sub" def test_subscriber_default_no_tenant_raises_at_call_time(self): - """SUBSCRIBER_ONLY default with no tenant raises at first call.""" + """SUBSCRIBER default with no tenant raises at first call.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) with pytest.raises(AgentMemoryValidationError, match="tenant"): client._resolve_tenant(None, None) @@ -102,36 +102,36 @@ def test_per_call_tenant_overrides_default(self): assert client._resolve_tenant(None, "override-sub") == "override-sub" def test_per_call_provider_overrides_subscriber_default(self): - """Per-call PROVIDER_ONLY overrides a SUBSCRIBER_ONLY instance default.""" + """Per-call PROVIDER overrides a SUBSCRIBER instance default.""" client = self._subscriber_client("default-sub") - assert client._resolve_tenant(AccessStrategy.PROVIDER_ONLY, None) is None + assert client._resolve_tenant(AccessStrategy.PROVIDER, None) is None def test_provider_default_resolves_to_none(self): - """PROVIDER_ONLY default returns None.""" + """PROVIDER default returns None.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) assert client._resolve_tenant(None, None) is None def test_per_call_subscriber_overrides_provider_default(self): - """Per-call SUBSCRIBER_ONLY overrides a PROVIDER_ONLY instance default.""" + """Per-call SUBSCRIBER overrides a PROVIDER instance default.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) - assert client._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, "sub") == "sub" + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) + assert client._resolve_tenant(AccessStrategy.SUBSCRIBER, "sub") == "sub" def test_per_call_subscriber_without_tenant_raises(self): - """Per-call SUBSCRIBER_ONLY with no tenant raises even when default is PROVIDER_ONLY.""" + """Per-call SUBSCRIBER with no tenant raises even when default is PROVIDER.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) with pytest.raises(AgentMemoryValidationError, match="tenant"): - client._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, None) + client._resolve_tenant(AccessStrategy.SUBSCRIBER, None) # ── Client-level defaults flow through to transport ─────────────────────── def test_client_default_subscriber_passes_tenant_without_per_call_params(self): - """Client with SUBSCRIBER_ONLY default uses default tenant on transport call.""" + """Client with SUBSCRIBER default uses default tenant on transport call.""" transport = MagicMock(spec=HttpTransport) transport.post.return_value = {"id": "m1", "agentID": "a", "invokerID": "u", "content": "x"} - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="default-sub") + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant="default-sub") client.add_memory("a", "u", "x") @@ -141,7 +141,7 @@ def test_per_call_tenant_overrides_default_on_transport(self): """Per-call tenant overrides client default when forwarded to transport.""" transport = MagicMock(spec=HttpTransport) transport.post.return_value = {"id": "m1", "agentID": "a", "invokerID": "u", "content": "x"} - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="default-sub") + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant="default-sub") client.add_memory("a", "u", "x", tenant="override-sub") @@ -150,7 +150,7 @@ def test_per_call_tenant_overrides_default_on_transport(self): # ── Per-call explicit params (existing behaviour) ───────────────────────── def test_add_memory_subscriber_only_passes_tenant_to_transport(self): - """add_memory with per-call SUBSCRIBER_ONLY passes tenant_subdomain to transport.""" + """add_memory with per-call SUBSCRIBER passes tenant_subdomain to transport.""" client, mock_transport = _make_client() mock_transport.post.return_value = { "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", @@ -158,53 +158,53 @@ def test_add_memory_subscriber_only_passes_tenant_to_transport(self): client.add_memory( "a", "u", "x", - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="sub-tenant", ) assert mock_transport.post.call_args[1]["tenant_subdomain"] == "sub-tenant" def test_add_memory_provider_only_passes_none_to_transport(self): - """add_memory with PROVIDER_ONLY passes tenant_subdomain=None to transport.""" + """add_memory with PROVIDER passes tenant_subdomain=None to transport.""" client, mock_transport = _make_client() mock_transport.post.return_value = { "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) assert mock_transport.post.call_args[1]["tenant_subdomain"] is None def test_add_memory_subscriber_only_without_tenant_raises(self): - """add_memory with per-call SUBSCRIBER_ONLY and no tenant raises before transport call.""" + """add_memory with per-call SUBSCRIBER and no tenant raises before transport call.""" client, mock_transport = _make_client() with pytest.raises(AgentMemoryValidationError, match="tenant"): client.add_memory( - "a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER_ONLY + "a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER ) mock_transport.post.assert_not_called() def test_list_memories_subscriber_only_passes_tenant_to_transport(self): - """list_memories with SUBSCRIBER_ONLY passes tenant_subdomain to transport.""" + """list_memories with SUBSCRIBER passes tenant_subdomain to transport.""" client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} client.list_memories( agent_id="a", - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="sub", ) assert mock_transport.get.call_args[1]["tenant_subdomain"] == "sub" def test_list_memories_provider_only_passes_none_to_transport(self): - """list_memories with PROVIDER_ONLY passes tenant_subdomain=None to transport.""" + """list_memories with PROVIDER passes tenant_subdomain=None to transport.""" client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="a", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(agent_id="a", access_strategy=AccessStrategy.PROVIDER) assert mock_transport.get.call_args[1]["tenant_subdomain"] is None @@ -225,7 +225,7 @@ def test_add_memory_posts_correct_payload(self): "createType": "DIRECT", } - memory = client.add_memory("agent-a", "user-b", "some memory", access_strategy=AccessStrategy.PROVIDER_ONLY) + memory = client.add_memory("agent-a", "user-b", "some memory", access_strategy=AccessStrategy.PROVIDER) assert isinstance(memory, Memory) assert memory.id == "mem-1" @@ -241,7 +241,7 @@ def test_add_memory_with_metadata(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("a", "u", "x", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -253,7 +253,7 @@ def test_add_memory_excludes_none_optionals(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -266,7 +266,7 @@ def test_add_memory_posts_to_memories_endpoint(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORIES @@ -278,7 +278,7 @@ def test_get_memory_calls_get_with_memory_id(self): "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "hello", } - memory = client.get_memory("mem-1", access_strategy=AccessStrategy.PROVIDER_ONLY) + memory = client.get_memory("mem-1", access_strategy=AccessStrategy.PROVIDER) assert memory.id == "mem-1" call_path = mock_transport.get.call_args[0][0] @@ -288,7 +288,7 @@ def test_update_memory_calls_patch(self): """update_memory sends a PATCH with the updated fields.""" client, mock_transport = _make_client() - client.update_memory("mem-1", content="updated", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_memory("mem-1", content="updated", access_strategy=AccessStrategy.PROVIDER) mock_transport.patch.assert_called_once() payload = mock_transport.patch.call_args[1]["json"] @@ -298,7 +298,7 @@ def test_update_memory_excludes_none_fields(self): """update_memory omits None-valued optional fields from the PATCH body.""" client, mock_transport = _make_client() - client.update_memory("mem-1", content="x", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_memory("mem-1", content="x", access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.patch.call_args[1]["json"] assert "metadata" not in payload @@ -307,7 +307,7 @@ def test_update_memory_with_metadata_only(self): """update_memory supports updating metadata without content.""" client, mock_transport = _make_client() - client.update_memory("mem-1", metadata={"key": "new-meta"}, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_memory("mem-1", metadata={"key": "new-meta"}, access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.patch.call_args[1]["json"] assert payload["metadata"] == {"key": "new-meta"} @@ -317,7 +317,7 @@ def test_delete_memory_calls_delete(self): """delete_memory sends a DELETE to the correct path.""" client, mock_transport = _make_client() - client.delete_memory("mem-1", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.delete_memory("mem-1", access_strategy=AccessStrategy.PROVIDER) mock_transport.delete.assert_called_once() call_path = mock_transport.delete.call_args[0][0] @@ -338,7 +338,7 @@ def test_returns_list_of_memories(self): ], } - memories = client.list_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER_ONLY) + memories = client.list_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER) assert len(memories) == 1 assert isinstance(memories[0], Memory) @@ -348,7 +348,7 @@ def test_passes_filter_for_agent_and_invoker(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="agent-x", invoker_id="user-y", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(agent_id="agent-x", invoker_id="user-y", access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert "agentID eq 'agent-x'" in params["$filter"] @@ -359,7 +359,7 @@ def test_default_limit_is_50(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "50" @@ -369,7 +369,7 @@ def test_custom_limit(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(limit=5, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(limit=5, access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "5" @@ -379,7 +379,7 @@ def test_empty_list(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - memories = client.list_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) + memories = client.list_memories(access_strategy=AccessStrategy.PROVIDER) assert len(memories) == 0 @@ -388,7 +388,7 @@ def test_offset_passes_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(offset=50, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(offset=50, access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$skip"] == "50" @@ -398,7 +398,7 @@ def test_zero_offset_omits_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert "$skip" not in params @@ -410,7 +410,7 @@ def test_filter_metadata_contains_adds_contains_clause(self): client.list_memories( filters=[FilterDefinition(target="metadata", contains="john")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -423,7 +423,7 @@ def test_filter_content_contains_adds_contains_clause(self): client.list_memories( filters=[FilterDefinition(target="content", contains="dark mode")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -439,7 +439,7 @@ def test_filter_multiple_clauses_joined_with_and(self): FilterDefinition(target="metadata", contains="john"), FilterDefinition(target="content", contains="user prefers"), ], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -457,7 +457,7 @@ def test_filter_combines_with_agent_and_invoker_filters(self): agent_id="my-agent", invoker_id="user-1", filters=[FilterDefinition(target="content", contains="dark mode")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -471,7 +471,7 @@ def test_filter_none_does_not_change_behaviour(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -484,7 +484,7 @@ def test_returns_count_from_response(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 42} - total = client.count_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER_ONLY) + total = client.count_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER) assert total == 42 @@ -493,7 +493,7 @@ def test_sends_top_0_and_count_true(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 0} - client.count_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) + client.count_memories(access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "0" @@ -504,7 +504,7 @@ def test_passes_filter_when_agent_and_invoker_provided(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 3} - client.count_memories(agent_id="agt", invoker_id="usr", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.count_memories(agent_id="agt", invoker_id="usr", access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert "agentID eq 'agt'" in params["$filter"] @@ -515,7 +515,7 @@ def test_returns_zero_when_count_missing(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - total = client.count_memories(access_strategy=AccessStrategy.PROVIDER_ONLY) + total = client.count_memories(access_strategy=AccessStrategy.PROVIDER) assert total == 0 @@ -535,7 +535,7 @@ def test_returns_results_in_api_order(self): ] } - results = client.search_memories("a", "u", "test query", access_strategy=AccessStrategy.PROVIDER_ONLY) + results = client.search_memories("a", "u", "test query", access_strategy=AccessStrategy.PROVIDER) assert len(results) == 2 assert all(isinstance(r, SearchResult) for r in results) @@ -547,7 +547,7 @@ def test_posts_correct_payload(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5, access_strategy=AccessStrategy.PROVIDER) call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORY_SEARCH @@ -563,7 +563,7 @@ def test_empty_results(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - results = client.search_memories("a", "u", "empty query", access_strategy=AccessStrategy.PROVIDER_ONLY) + results = client.search_memories("a", "u", "empty query", access_strategy=AccessStrategy.PROVIDER) assert len(results) == 0 @@ -572,7 +572,7 @@ def test_uses_default_threshold_and_limit(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("a", "u", "query", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "query", access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.post.call_args[1]["json"] assert payload["threshold"] == 0.6 @@ -599,7 +599,7 @@ def test_add_message_posts_correct_payload(self): message = client.add_message( "agent-a", "user-b", "conv-1", MessageRole.USER, "Hello!", - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) assert isinstance(message, Message) @@ -620,7 +620,7 @@ def test_add_message_posts_to_messages_endpoint(self): "messageGroup": "g", "role": "USER", "content": "hi", } - client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) call_path = mock_transport.post.call_args[0][0] assert call_path == MESSAGES @@ -634,7 +634,7 @@ def test_add_message_with_metadata(self): "metadata": {"key": "val"}, } - client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -647,7 +647,7 @@ def test_add_message_excludes_none_metadata(self): "messageGroup": "g", "role": "USER", "content": "hi", } - client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -660,7 +660,7 @@ def test_get_message_calls_get_with_message_id(self): "messageGroup": "g", "role": "USER", "content": "hi", } - message = client.get_message("msg-1", access_strategy=AccessStrategy.PROVIDER_ONLY) + message = client.get_message("msg-1", access_strategy=AccessStrategy.PROVIDER) assert message.id == "msg-1" call_path = mock_transport.get.call_args[0][0] @@ -670,7 +670,7 @@ def test_delete_message_calls_delete(self): """delete_message sends a DELETE to the correct path.""" client, mock_transport = _make_client() - client.delete_message("msg-1", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.delete_message("msg-1", access_strategy=AccessStrategy.PROVIDER) mock_transport.delete.assert_called_once() call_path = mock_transport.delete.call_args[0][0] @@ -694,7 +694,7 @@ def test_returns_list_of_messages(self): ], } - messages = client.list_messages(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER_ONLY) + messages = client.list_messages(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER) assert len(messages) == 1 assert isinstance(messages[0], Message) @@ -707,7 +707,7 @@ def test_passes_convenience_filters(self): client.list_messages( agent_id="a", invoker_id="u", message_group="conv-1", role="USER", - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -722,7 +722,7 @@ def test_default_limit_is_50(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "50" @@ -732,7 +732,7 @@ def test_custom_limit(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(limit=20, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(limit=20, access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "20" @@ -742,7 +742,7 @@ def test_empty_list(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - messages = client.list_messages(access_strategy=AccessStrategy.PROVIDER_ONLY) + messages = client.list_messages(access_strategy=AccessStrategy.PROVIDER) assert len(messages) == 0 @@ -751,7 +751,7 @@ def test_offset_passes_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(offset=100, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(offset=100, access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$skip"] == "100" @@ -761,7 +761,7 @@ def test_zero_offset_omits_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert "$skip" not in params @@ -773,7 +773,7 @@ def test_filter_metadata_contains_adds_contains_clause(self): client.list_messages( filters=[FilterDefinition(target="metadata", contains="demo-app")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -786,7 +786,7 @@ def test_filter_content_contains_adds_contains_clause(self): client.list_messages( filters=[FilterDefinition(target="content", contains="invoice")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -802,7 +802,7 @@ def test_filter_multiple_clauses_joined_with_and(self): FilterDefinition(target="metadata", contains="john"), FilterDefinition(target="content", contains="user prefers"), ], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -822,7 +822,7 @@ def test_filter_combines_with_convenience_filters(self): message_group="g", role="USER", filters=[FilterDefinition(target="content", contains="hello")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -838,7 +838,7 @@ def test_filter_none_does_not_change_behaviour(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER) params = mock_transport.get.call_args[1]["params"] assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -859,7 +859,7 @@ def test_get_retention_config(self): "updateTimestamp": "2025-01-02T00:00:00Z", } - rc = client.get_retention_config(access_strategy=AccessStrategy.PROVIDER_ONLY) + rc = client.get_retention_config(access_strategy=AccessStrategy.PROVIDER) assert isinstance(rc, RetentionConfig) assert rc.id == 1 @@ -873,7 +873,7 @@ def test_update_retention_config(self): """update_retention_config sends PATCH with updated fields.""" client, mock_transport = _make_client() - client.update_retention_config(message_days=60, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(message_days=60, access_strategy=AccessStrategy.PROVIDER) mock_transport.patch.assert_called_once() call_path = mock_transport.patch.call_args[0][0] @@ -886,7 +886,7 @@ def test_update_retention_config_excludes_none_fields(self): """update_retention_config omits None-valued fields from PATCH body.""" client, mock_transport = _make_client() - client.update_retention_config(memory_days=90, usage_log_days=180, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(memory_days=90, usage_log_days=180, access_strategy=AccessStrategy.PROVIDER) payload = mock_transport.patch.call_args[1]["json"] assert "messageDays" not in payload @@ -910,7 +910,7 @@ def test_close_delegates_to_transport(self): def test_context_manager_closes_on_exit(self): """Using the client as a context manager closes it on __exit__.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) with client: pass @@ -927,55 +927,55 @@ def test_add_memory_raises_for_empty_agent_id(self): """add_memory raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.add_memory("", "user-1", "content", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("", "user-1", "content", access_strategy=AccessStrategy.PROVIDER) def test_add_memory_raises_for_empty_invoker_id(self): """add_memory raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.add_memory("agent-1", "", "content", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("agent-1", "", "content", access_strategy=AccessStrategy.PROVIDER) def test_add_memory_raises_for_empty_content(self): """add_memory raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): - client.add_memory("agent-1", "user-1", "", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_memory("agent-1", "user-1", "", access_strategy=AccessStrategy.PROVIDER) def test_get_memory_raises_for_empty_id(self): """get_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.get_memory("", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.get_memory("", access_strategy=AccessStrategy.PROVIDER) def test_update_memory_raises_for_empty_id(self): """update_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.update_memory("", content="new content", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_memory("", content="new content", access_strategy=AccessStrategy.PROVIDER) def test_update_memory_raises_when_no_fields_provided(self): """update_memory raises AgentMemoryValidationError when neither content nor metadata is provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): - client.update_memory("uuid-123", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_memory("uuid-123", access_strategy=AccessStrategy.PROVIDER) def test_delete_memory_raises_for_empty_id(self): """delete_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.delete_memory("", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.delete_memory("", access_strategy=AccessStrategy.PROVIDER) def test_list_memories_raises_for_zero_limit(self): """list_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.list_memories(limit=0, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(limit=0, access_strategy=AccessStrategy.PROVIDER) def test_list_memories_raises_for_negative_offset(self): """list_memories raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): - client.list_memories(offset=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_memories(offset=-1, access_strategy=AccessStrategy.PROVIDER) class TestSearchMemoriesValidation: @@ -984,57 +984,57 @@ def test_raises_for_empty_agent_id(self): """search_memories raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.search_memories("", "user-1", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("", "user-1", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER) def test_raises_for_empty_invoker_id(self): """search_memories raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.search_memories("agent-1", "", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("agent-1", "", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER) def test_raises_for_query_too_short(self): """search_memories raises AgentMemoryValidationError when query has fewer than 5 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): - client.search_memories("agent-1", "user-1", "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("agent-1", "user-1", "hi", access_strategy=AccessStrategy.PROVIDER) def test_raises_for_query_too_long(self): """search_memories raises AgentMemoryValidationError when query exceeds 5000 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): - client.search_memories("agent-1", "user-1", "x" * 5001, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("agent-1", "user-1", "x" * 5001, access_strategy=AccessStrategy.PROVIDER) def test_raises_for_threshold_below_zero(self): """search_memories raises AgentMemoryValidationError when threshold < 0.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): - client.search_memories("a", "u", "valid query here", threshold=-0.1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "valid query here", threshold=-0.1, access_strategy=AccessStrategy.PROVIDER) def test_raises_for_threshold_above_one(self): """search_memories raises AgentMemoryValidationError when threshold > 1.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): - client.search_memories("a", "u", "valid query here", threshold=1.1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "valid query here", threshold=1.1, access_strategy=AccessStrategy.PROVIDER) def test_raises_for_limit_zero(self): """search_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.search_memories("a", "u", "valid query here", limit=0, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "valid query here", limit=0, access_strategy=AccessStrategy.PROVIDER) def test_raises_for_limit_above_fifty(self): """search_memories raises AgentMemoryValidationError when limit exceeds 50.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.search_memories("a", "u", "valid query here", limit=51, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "valid query here", limit=51, access_strategy=AccessStrategy.PROVIDER) def test_boundary_values_are_accepted(self): """search_memories accepts boundary values: 5-char query, threshold 0.0/1.0, limit 1/50.""" client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("a", "u", "hello", threshold=0.0, limit=1, access_strategy=AccessStrategy.PROVIDER_ONLY) - client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.search_memories("a", "u", "hello", threshold=0.0, limit=1, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50, access_strategy=AccessStrategy.PROVIDER) assert mock_transport.post.call_count == 2 @@ -1045,49 +1045,49 @@ def test_add_message_raises_for_empty_agent_id(self): """add_message raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.add_message("", "u", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("", "u", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) def test_add_message_raises_for_empty_invoker_id(self): """add_message raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.add_message("a", "", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("a", "", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) def test_add_message_raises_for_empty_message_group(self): """add_message raises AgentMemoryValidationError when message_group is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_group"): - client.add_message("a", "u", "", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("a", "u", "", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) def test_add_message_raises_for_empty_content(self): """add_message raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): - client.add_message("a", "u", "grp", MessageRole.USER, "", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.add_message("a", "u", "grp", MessageRole.USER, "", access_strategy=AccessStrategy.PROVIDER) def test_get_message_raises_for_empty_id(self): """get_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): - client.get_message("", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.get_message("", access_strategy=AccessStrategy.PROVIDER) def test_delete_message_raises_for_empty_id(self): """delete_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): - client.delete_message("", access_strategy=AccessStrategy.PROVIDER_ONLY) + client.delete_message("", access_strategy=AccessStrategy.PROVIDER) def test_list_messages_raises_for_zero_limit(self): """list_messages raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.list_messages(limit=0, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(limit=0, access_strategy=AccessStrategy.PROVIDER) def test_list_messages_raises_for_negative_offset(self): """list_messages raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): - client.list_messages(offset=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.list_messages(offset=-1, access_strategy=AccessStrategy.PROVIDER) class TestRetentionConfigValidation: @@ -1096,31 +1096,31 @@ def test_update_raises_when_no_fields_provided(self): """update_retention_config raises AgentMemoryValidationError when no fields are provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): - client.update_retention_config(access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(access_strategy=AccessStrategy.PROVIDER) def test_update_raises_for_negative_message_days(self): """update_retention_config raises AgentMemoryValidationError when message_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_days"): - client.update_retention_config(message_days=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(message_days=-1, access_strategy=AccessStrategy.PROVIDER) def test_update_raises_for_negative_memory_days(self): """update_retention_config raises AgentMemoryValidationError when memory_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_days"): - client.update_retention_config(memory_days=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(memory_days=-1, access_strategy=AccessStrategy.PROVIDER) def test_update_raises_for_negative_usage_log_days(self): """update_retention_config raises AgentMemoryValidationError when usage_log_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="usage_log_days"): - client.update_retention_config(usage_log_days=-1, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(usage_log_days=-1, access_strategy=AccessStrategy.PROVIDER) def test_update_accepts_zero_values(self): """update_retention_config accepts 0 as a valid value (disables cleanup).""" client, mock_transport = _make_client() - client.update_retention_config(memory_days=0, access_strategy=AccessStrategy.PROVIDER_ONLY) + client.update_retention_config(memory_days=0, access_strategy=AccessStrategy.PROVIDER) mock_transport.patch.assert_called_once() @@ -1136,7 +1136,7 @@ def test_list_memories_raises_for_unsupported_target(self): with pytest.raises(AgentMemoryValidationError, match="target"): client.list_memories( filters=[FilterDefinition(target="agentID", contains="x")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) def test_list_memories_raises_for_empty_contains(self): @@ -1145,7 +1145,7 @@ def test_list_memories_raises_for_empty_contains(self): with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_memories( filters=[FilterDefinition(target="content", contains="")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) def test_list_messages_raises_for_unsupported_target(self): @@ -1154,7 +1154,7 @@ def test_list_messages_raises_for_unsupported_target(self): with pytest.raises(AgentMemoryValidationError, match="target"): client.list_messages( filters=[FilterDefinition(target="role", contains="x")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) def test_list_messages_raises_for_empty_contains(self): @@ -1163,5 +1163,5 @@ def test_list_messages_raises_for_empty_contains(self): with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_messages( filters=[FilterDefinition(target="metadata", contains="")], - access_strategy=AccessStrategy.PROVIDER_ONLY, + access_strategy=AccessStrategy.PROVIDER, ) From 50aebb0a85e84648a02a7744952415e12305ce68 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Fri, 17 Jul 2026 16:51:01 -0300 Subject: [PATCH 6/6] refactor(agent-memory): align public API with bindings branch - Remove access_strategy/tenant per-call params from all 13 methods - Validate access_strategy/tenant at __init__ time (same as bindings branch) - Store self._tenant at init; pass tenant_subdomain=self._tenant to transport on every call (preserves identityzone token URL derivation internally) - Update tests: remove _resolve_tenant tests, add init-time validation tests, remove per-call kwargs from all call sites - Public API now identical to feat/enable-multitenancy-support-for-agent-memory-using-bindings --- src/sap_cloud_sdk/agent_memory/client.py | 155 ++---- .../integration/test_agentmemory_bdd.py | 44 -- tests/agent_memory/unit/test_client.py | 476 ++++++++---------- 3 files changed, 255 insertions(+), 420 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index db116ba3..3d8ee151 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -78,10 +78,10 @@ class AgentMemoryClient: Args: transport: HTTP transport layer (injected by ``create_client``). - access_strategy: Default tenant access strategy for all operations. - Defaults to ``SUBSCRIBER``. Can be overridden per method call. - tenant: Default subscriber tenant subdomain. Required when - ``access_strategy=SUBSCRIBER``. Can be overridden per method call. + access_strategy: Tenant access strategy for all operations. + Defaults to ``SUBSCRIBER``. + tenant: Subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER``. """ def __init__( @@ -91,9 +91,17 @@ def __init__( access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, tenant: Optional[str] = None, ) -> None: + if access_strategy is AccessStrategy.SUBSCRIBER and not tenant: + raise AgentMemoryValidationError( + "tenant is required when access_strategy=SUBSCRIBER" + ) + if access_strategy is AccessStrategy.PROVIDER: + logger.warning( + "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " + "Only use this strategy for provider-owned operations." + ) self._transport = transport - self._default_access_strategy = access_strategy - self._default_tenant = tenant + self._tenant = tenant if access_strategy is AccessStrategy.SUBSCRIBER else None def close(self) -> None: """Close the underlying HTTP session and release resources.""" @@ -105,41 +113,6 @@ def __enter__(self) -> AgentMemoryClient: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() - def _resolve_tenant( - self, - access_strategy: Optional[AccessStrategy], - tenant: Optional[str], - ) -> Optional[str]: - """Return the tenant subdomain to use for token derivation. - - Per-call parameters take precedence over instance defaults. - - Raises: - AgentMemoryValidationError: If the effective strategy is ``SUBSCRIBER`` - but no tenant is available. - """ - effective_strategy = ( - access_strategy - if access_strategy is not None - else self._default_access_strategy - ) - effective_tenant = tenant if tenant is not None else self._default_tenant - - if effective_strategy is AccessStrategy.SUBSCRIBER and not effective_tenant: - raise AgentMemoryValidationError( - "tenant is required when access_strategy=SUBSCRIBER" - ) - if effective_strategy is not AccessStrategy.SUBSCRIBER: - logger.warning( - "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " - "Only use this strategy for provider-owned operations." - ) - return ( - effective_tenant - if effective_strategy is AccessStrategy.SUBSCRIBER - else None - ) - # ── Memory operations ────────────────────────────────────────────────────── @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_ADD_MEMORY) @@ -150,8 +123,6 @@ def add_memory( content: str, *, metadata: Optional[dict[str, Any]] = None, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> Memory: """Create a new memory entry. @@ -174,7 +145,6 @@ def add_memory( AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, content=content) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -183,18 +153,12 @@ def add_memory( if metadata is not None: payload["metadata"] = metadata data = self._transport.post( - MEMORIES, json=payload, tenant_subdomain=tenant_subdomain + MEMORIES, json=payload, tenant_subdomain=self._tenant ) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MEMORY) - def get_memory( - self, - memory_id: str, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, - ) -> Memory: + def get_memory(self, memory_id: str) -> Memory: """Retrieve a memory by ID. Args: @@ -214,9 +178,8 @@ def get_memory( AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) data = self._transport.get( - f"{MEMORIES}({memory_id})", tenant_subdomain=tenant_subdomain + f"{MEMORIES}({memory_id})", tenant_subdomain=self._tenant ) return Memory.from_dict(data) @@ -227,8 +190,6 @@ def update_memory( *, content: Optional[str] = None, metadata: Optional[dict[str, Any]] = None, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> None: """Update a memory's content and/or metadata. @@ -252,24 +213,17 @@ def update_memory( raise AgentMemoryValidationError( "At least one of 'content' or 'metadata' must be provided" ) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = {} if content is not None: payload["content"] = content if metadata is not None: payload["metadata"] = metadata self._transport.patch( - f"{MEMORIES}({memory_id})", json=payload, tenant_subdomain=tenant_subdomain + f"{MEMORIES}({memory_id})", json=payload, tenant_subdomain=self._tenant ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MEMORY) - def delete_memory( - self, - memory_id: str, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, - ) -> None: + def delete_memory(self, memory_id: str) -> None: """Delete a memory permanently. Args: @@ -286,9 +240,8 @@ def delete_memory( AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) self._transport.delete( - f"{MEMORIES}({memory_id})", tenant_subdomain=tenant_subdomain + f"{MEMORIES}({memory_id})", tenant_subdomain=self._tenant ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MEMORIES) @@ -300,8 +253,6 @@ def list_memories( filters: Optional[list[FilterDefinition]] = None, limit: int = 50, offset: int = 0, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> list[Memory]: """List memories, optionally filtered by agent and/or invoker. @@ -334,7 +285,6 @@ def list_memories( raise AgentMemoryValidationError("'offset' must be >= 0") if filters is not None: _validate_filter_clauses(filters, {"metadata", "content"}) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) params = build_list_params( filter_expr=build_memory_filter( agent_id=agent_id, @@ -345,19 +295,14 @@ def list_memories( skip=offset if offset else None, ) response = self._transport.get( - MEMORIES, params=params, tenant_subdomain=tenant_subdomain + MEMORIES, params=params, tenant_subdomain=self._tenant ) items, _ = extract_value_and_count(response) return [Memory.from_dict(item) for item in items] @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_COUNT_MEMORIES) def count_memories( - self, - agent_id: Optional[str] = None, - invoker_id: Optional[str] = None, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, + self, agent_id: Optional[str] = None, invoker_id: Optional[str] = None ) -> int: """Count memories matching the given filters. @@ -376,14 +321,13 @@ def count_memories( AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) params = build_list_params( filter_expr=build_memory_filter(agent_id=agent_id, invoker_id=invoker_id), top=0, count=True, ) response = self._transport.get( - MEMORIES, params=params, tenant_subdomain=tenant_subdomain + MEMORIES, params=params, tenant_subdomain=self._tenant ) _, total = extract_value_and_count(response) return total or 0 @@ -396,9 +340,6 @@ def search_memories( query: str, threshold: float = 0.6, limit: int = 10, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> list[SearchResult]: """Perform a semantic (vector) search over stored memories. @@ -431,7 +372,6 @@ def search_memories( raise AgentMemoryValidationError("'threshold' must be between 0.0 and 1.0") if not (1 <= limit <= 50): raise AgentMemoryValidationError("'limit' must be between 1 and 50") - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -440,7 +380,7 @@ def search_memories( "top": limit, } response = self._transport.post( - MEMORY_SEARCH, json=payload, tenant_subdomain=tenant_subdomain + MEMORY_SEARCH, json=payload, tenant_subdomain=self._tenant ) items = response.get("value", []) return [SearchResult.from_dict(item) for item in items] @@ -457,8 +397,6 @@ def add_message( content: str, *, metadata: Optional[dict[str, Any]] = None, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> Message: """Create a new message. @@ -491,7 +429,6 @@ def add_message( message_group=message_group, content=content, ) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -502,18 +439,12 @@ def add_message( if metadata is not None: payload["metadata"] = metadata data = self._transport.post( - MESSAGES, json=payload, tenant_subdomain=tenant_subdomain + MESSAGES, json=payload, tenant_subdomain=self._tenant ) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MESSAGE) - def get_message( - self, - message_id: str, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, - ) -> Message: + def get_message(self, message_id: str) -> Message: """Retrieve a message by ID. Args: @@ -533,20 +464,13 @@ def get_message( AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) data = self._transport.get( - f"{MESSAGES}({message_id})", tenant_subdomain=tenant_subdomain + f"{MESSAGES}({message_id})", tenant_subdomain=self._tenant ) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MESSAGE) - def delete_message( - self, - message_id: str, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, - ) -> None: + def delete_message(self, message_id: str) -> None: """Delete a message permanently. Args: @@ -563,9 +487,8 @@ def delete_message( AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) self._transport.delete( - f"{MESSAGES}({message_id})", tenant_subdomain=tenant_subdomain + f"{MESSAGES}({message_id})", tenant_subdomain=self._tenant ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MESSAGES) @@ -579,8 +502,6 @@ def list_messages( filters: Optional[list[FilterDefinition]] = None, limit: int = 50, offset: int = 0, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> list[Message]: """List messages, optionally filtered by agent, invoker, group, and role. @@ -615,7 +536,6 @@ def list_messages( raise AgentMemoryValidationError("'offset' must be >= 0") if filters is not None: _validate_filter_clauses(filters, {"metadata", "content"}) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) params = build_list_params( filter_expr=build_message_filter( agent_id=agent_id, @@ -628,7 +548,7 @@ def list_messages( skip=offset if offset else None, ) response = self._transport.get( - MESSAGES, params=params, tenant_subdomain=tenant_subdomain + MESSAGES, params=params, tenant_subdomain=self._tenant ) items, _ = extract_value_and_count(response) return [Message.from_dict(item) for item in items] @@ -636,12 +556,7 @@ def list_messages( # ── Admin operations ─────────────────────────────────────────────────────── @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_RETENTION_CONFIG) - def get_retention_config( - self, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, - ) -> RetentionConfig: + def get_retention_config(self) -> RetentionConfig: """Retrieve the data retention configuration (singleton). Args: @@ -657,8 +572,7 @@ def get_retention_config( AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) - data = self._transport.get(RETENTION_CONFIG, tenant_subdomain=tenant_subdomain) + data = self._transport.get(RETENTION_CONFIG, tenant_subdomain=self._tenant) return RetentionConfig.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_RETENTION_CONFIG) @@ -668,8 +582,6 @@ def update_retention_config( message_days: Optional[int] = None, memory_days: Optional[int] = None, usage_log_days: Optional[int] = None, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, ) -> None: """Update the data retention configuration. @@ -703,7 +615,6 @@ def update_retention_config( ): if value is not None and value < 0: raise AgentMemoryValidationError(f"'{name}' must be >= 0") - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) payload: dict[str, Any] = {} if message_days is not None: payload["messageDays"] = message_days @@ -712,5 +623,5 @@ def update_retention_config( if usage_log_days is not None: payload["usageLogDays"] = usage_log_days self._transport.patch( - RETENTION_CONFIG, json=payload, tenant_subdomain=tenant_subdomain + RETENTION_CONFIG, json=payload, tenant_subdomain=self._tenant ) diff --git a/tests/agent_memory/integration/test_agentmemory_bdd.py b/tests/agent_memory/integration/test_agentmemory_bdd.py index df71e438..b0caab21 100644 --- a/tests/agent_memory/integration/test_agentmemory_bdd.py +++ b/tests/agent_memory/integration/test_agentmemory_bdd.py @@ -191,8 +191,6 @@ def memory_exists(context, agent_memory_client, agent_id, invoker_id, content): context["client"] = agent_memory_client context["memory"] = agent_memory_client.add_memory( agent_id, invoker_id, content, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -205,8 +203,6 @@ def message_exists(context, agent_memory_client, agent_id, invoker_id, group, ro context["client"] = agent_memory_client context["message"] = agent_memory_client.add_message( agent_id, invoker_id, group, role, content, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -220,8 +216,6 @@ def message_exists_with_metadata(context, agent_memory_client, agent_id, invoker context["message"] = agent_memory_client.add_message( agent_id, invoker_id, group, role, content, metadata={"tag": metadata_value}, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -237,8 +231,6 @@ def add_memory(context, agent_id, invoker_id, content): client: AgentMemoryClient = context["client"] context["memory"] = client.add_memory( agent_id, invoker_id, content, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -247,8 +239,6 @@ def get_memory(context): client: AgentMemoryClient = context["client"] context["fetched_memory"] = client.get_memory( context["memory"].id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -257,13 +247,9 @@ def update_memory(context, content): client: AgentMemoryClient = context["client"] client.update_memory( context["memory"].id, content=content, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) context["memory"] = client.get_memory( context["memory"].id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -272,13 +258,9 @@ def list_memories(context, agent_id): client: AgentMemoryClient = context["client"] context["memories"] = client.list_memories( agent_id=agent_id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) context["total"] = client.count_memories( agent_id=agent_id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -287,8 +269,6 @@ def delete_memory(context): client: AgentMemoryClient = context["client"] client.delete_memory( context["memory"].id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) context["deleted_memory_id"] = context["memory"].id @@ -302,8 +282,6 @@ def search_memories(context, query): query=query, threshold=0.5, limit=10, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -316,8 +294,6 @@ def add_message(context, agent_id, invoker_id, group, role, content): client: AgentMemoryClient = context["client"] context["message"] = client.add_message( agent_id, invoker_id, group, MessageRole(role), content, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -331,8 +307,6 @@ def list_messages(context, agent_id, group): context["messages"] = client.list_messages( agent_id=agent_id, message_group=group, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) context["total"] = len(context["messages"]) @@ -342,8 +316,6 @@ def delete_message(context): client: AgentMemoryClient = context["client"] client.delete_message( context["message"].id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) context["deleted_message_id"] = context["message"].id @@ -352,8 +324,6 @@ def delete_message(context): def get_retention_config(context): client: AgentMemoryClient = context["client"] context["retention_config"] = client.get_retention_config( - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -362,12 +332,8 @@ def update_retention_config(context): client: AgentMemoryClient = context["client"] client.update_retention_config( message_days=30, memory_days=90, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) context["retention_config"] = client.get_retention_config( - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -381,8 +347,6 @@ def count_memories(context, agent_id, invoker_id): context["memory_count"] = client.count_memories( agent_id=agent_id, invoker_id=invoker_id, - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -393,8 +357,6 @@ def list_memories_by_content(context, substring): agent_id="test-agent", invoker_id="test-user", filters=[FilterDefinition(target="content", contains=substring)], - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -406,8 +368,6 @@ def list_messages_by_metadata(context, substring): invoker_id="test-user", message_group="conv-filter", filters=[FilterDefinition(target="metadata", contains=substring)], - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -459,8 +419,6 @@ def check_memory_deleted(context): with pytest.raises(AgentMemoryNotFoundError): client.get_memory( context["deleted_memory_id"], - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) @@ -503,8 +461,6 @@ def check_message_deleted(context): with pytest.raises(AgentMemoryNotFoundError): client.get_message( context["deleted_message_id"], - access_strategy=context["access_strategy"], - tenant=context["tenant"], ) diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index 102ba86c..f027745a 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -23,6 +23,7 @@ from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError + def _make_client() -> tuple[AgentMemoryClient, MagicMock]: """Return an AgentMemoryClient with a mocked transport and PROVIDER default.""" transport = MagicMock(spec=HttpTransport) @@ -30,23 +31,34 @@ def _make_client() -> tuple[AgentMemoryClient, MagicMock]: return client, transport +def _make_subscriber_client( + tenant: str = "default-sub", +) -> tuple[AgentMemoryClient, MagicMock]: + """Return a client with SUBSCRIBER default and the given tenant.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient( + transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant=tenant + ) + return client, transport + + # ── create_client factory ───────────────────────────────────────────────────── class TestCreateClient: - def test_uses_provided_config_with_provider_strategy(self): - """Factory accepts an explicit config and PROVIDER strategy.""" + """Factory with explicit config and PROVIDER constructs successfully.""" config = AgentMemoryConfig(base_url="http://localhost:3000") with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client(config=config, access_strategy=AccessStrategy.PROVIDER) + client = create_client( + config=config, access_strategy=AccessStrategy.PROVIDER + ) assert isinstance(client, AgentMemoryClient) - assert client._default_access_strategy is AccessStrategy.PROVIDER - assert client._default_tenant is None + assert client._tenant is None def test_uses_provided_config_with_subscriber_strategy(self): - """Factory stores subscriber strategy and tenant on the client.""" + """Factory with explicit config and SUBSCRIBER stores tenant.""" config = AgentMemoryConfig(base_url="http://localhost:3000") with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) @@ -55,18 +67,26 @@ def test_uses_provided_config_with_subscriber_strategy(self): access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", ) - assert client._default_access_strategy is AccessStrategy.SUBSCRIBER - assert client._default_tenant == "acme-corp" + assert client._tenant == "acme-corp" def test_reads_env_when_no_config_provided(self, monkeypatch): """Factory falls back to environment variables when no config given.""" import json - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "http://memory.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", json.dumps({ - "url": "http://auth.example.com", - "clientid": "client-id", - "clientsecret": "client-secret", - })) + + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", + "http://memory.example.com", + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", + json.dumps( + { + "url": "http://auth.example.com", + "clientid": "client-id", + "clientsecret": "client-secret", + } + ), + ) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) client = create_client(access_strategy=AccessStrategy.PROVIDER) @@ -75,145 +95,70 @@ def test_reads_env_when_no_config_provided(self, monkeypatch): # ── Access strategy ─────────────────────────────────────────────────────────── -class TestAccessStrategy: - - def _subscriber_client(self, tenant="default-sub"): - """Helper: client with SUBSCRIBER default.""" - transport = MagicMock(spec=HttpTransport) - return AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant=tenant) - - # ── _resolve_tenant instance method ─────────────────────────────────────── - - def test_subscriber_default_with_no_override_returns_default_tenant(self): - """No per-call params → instance default tenant is used.""" - client = self._subscriber_client("default-sub") - assert client._resolve_tenant(None, None) == "default-sub" - - def test_subscriber_default_no_tenant_raises_at_call_time(self): - """SUBSCRIBER default with no tenant raises at first call.""" - transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) - with pytest.raises(AgentMemoryValidationError, match="tenant"): - client._resolve_tenant(None, None) - - def test_per_call_tenant_overrides_default(self): - """Per-call tenant overrides the instance default tenant.""" - client = self._subscriber_client("default-sub") - assert client._resolve_tenant(None, "override-sub") == "override-sub" - - def test_per_call_provider_overrides_subscriber_default(self): - """Per-call PROVIDER overrides a SUBSCRIBER instance default.""" - client = self._subscriber_client("default-sub") - assert client._resolve_tenant(AccessStrategy.PROVIDER, None) is None - - def test_provider_default_resolves_to_none(self): - """PROVIDER default returns None.""" - transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) - assert client._resolve_tenant(None, None) is None - def test_per_call_subscriber_overrides_provider_default(self): - """Per-call SUBSCRIBER overrides a PROVIDER instance default.""" - transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) - assert client._resolve_tenant(AccessStrategy.SUBSCRIBER, "sub") == "sub" +class TestAccessStrategy: + # ── Init-time validation ────────────────────────────────────────────────── - def test_per_call_subscriber_without_tenant_raises(self): - """Per-call SUBSCRIBER with no tenant raises even when default is PROVIDER.""" + def test_subscriber_without_tenant_raises_at_init(self): + """SUBSCRIBER without tenant raises AgentMemoryValidationError at construction.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) with pytest.raises(AgentMemoryValidationError, match="tenant"): - client._resolve_tenant(AccessStrategy.SUBSCRIBER, None) - - # ── Client-level defaults flow through to transport ─────────────────────── - - def test_client_default_subscriber_passes_tenant_without_per_call_params(self): - """Client with SUBSCRIBER default uses default tenant on transport call.""" - transport = MagicMock(spec=HttpTransport) - transport.post.return_value = {"id": "m1", "agentID": "a", "invokerID": "u", "content": "x"} - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant="default-sub") - - client.add_memory("a", "u", "x") - - assert transport.post.call_args[1]["tenant_subdomain"] == "default-sub" - - def test_per_call_tenant_overrides_default_on_transport(self): - """Per-call tenant overrides client default when forwarded to transport.""" - transport = MagicMock(spec=HttpTransport) - transport.post.return_value = {"id": "m1", "agentID": "a", "invokerID": "u", "content": "x"} - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER, tenant="default-sub") - - client.add_memory("a", "u", "x", tenant="override-sub") + AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) - assert transport.post.call_args[1]["tenant_subdomain"] == "override-sub" + def test_subscriber_with_tenant_constructs_successfully(self): + """SUBSCRIBER with tenant constructs without error and stores tenant.""" + client, _ = _make_subscriber_client("acme") + assert client._tenant == "acme" - # ── Per-call explicit params (existing behaviour) ───────────────────────── - - def test_add_memory_subscriber_only_passes_tenant_to_transport(self): - """add_memory with per-call SUBSCRIBER passes tenant_subdomain to transport.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { - "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + def test_provider_constructs_without_tenant(self): + """PROVIDER constructs without tenant and stores None.""" + client, _ = _make_client() + assert client._tenant is None + + # ── Transport routing ───────────────────────────────────────────────────── + + def test_subscriber_passes_tenant_to_transport(self): + """SUBSCRIBER client passes tenant_subdomain to transport on every call.""" + client, transport = _make_subscriber_client("acme") + transport.post.return_value = { + "id": "m1", + "agentID": "a", + "invokerID": "u", + "content": "x", } - client.add_memory( - "a", "u", "x", - access_strategy=AccessStrategy.SUBSCRIBER, - tenant="sub-tenant", - ) + client.add_memory("a", "u", "x") - assert mock_transport.post.call_args[1]["tenant_subdomain"] == "sub-tenant" + assert transport.post.call_args[1]["tenant_subdomain"] == "acme" - def test_add_memory_provider_only_passes_none_to_transport(self): - """add_memory with PROVIDER passes tenant_subdomain=None to transport.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { - "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + def test_provider_passes_none_tenant_to_transport(self): + """PROVIDER client passes tenant_subdomain=None to transport.""" + client, transport = _make_client() + transport.post.return_value = { + "id": "m1", + "agentID": "a", + "invokerID": "u", + "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) - - assert mock_transport.post.call_args[1]["tenant_subdomain"] is None - - def test_add_memory_subscriber_only_without_tenant_raises(self): - """add_memory with per-call SUBSCRIBER and no tenant raises before transport call.""" - client, mock_transport = _make_client() - - with pytest.raises(AgentMemoryValidationError, match="tenant"): - client.add_memory( - "a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER - ) - - mock_transport.post.assert_not_called() - - def test_list_memories_subscriber_only_passes_tenant_to_transport(self): - """list_memories with SUBSCRIBER passes tenant_subdomain to transport.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} - - client.list_memories( - agent_id="a", - access_strategy=AccessStrategy.SUBSCRIBER, - tenant="sub", - ) + client.add_memory("a", "u", "x") - assert mock_transport.get.call_args[1]["tenant_subdomain"] == "sub" + assert transport.post.call_args[1]["tenant_subdomain"] is None - def test_list_memories_provider_only_passes_none_to_transport(self): - """list_memories with PROVIDER passes tenant_subdomain=None to transport.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + def test_list_memories_passes_tenant_to_transport(self): + """list_memories passes tenant_subdomain from client config.""" + client, transport = _make_subscriber_client("sub") + transport.get.return_value = {"value": []} - client.list_memories(agent_id="a", access_strategy=AccessStrategy.PROVIDER) + client.list_memories(agent_id="a") - assert mock_transport.get.call_args[1]["tenant_subdomain"] is None + assert transport.get.call_args[1]["tenant_subdomain"] == "sub" # ── Memory CRUD operations ──────────────────────────────────────────────────── class TestMemoryCRUD: - def test_add_memory_posts_correct_payload(self): """add_memory sends required and optional fields in the POST body.""" client, mock_transport = _make_client() @@ -225,7 +170,7 @@ def test_add_memory_posts_correct_payload(self): "createType": "DIRECT", } - memory = client.add_memory("agent-a", "user-b", "some memory", access_strategy=AccessStrategy.PROVIDER) + memory = client.add_memory("agent-a", "user-b", "some memory") assert isinstance(memory, Memory) assert memory.id == "mem-1" @@ -238,10 +183,13 @@ def test_add_memory_with_metadata(self): """Optional metadata is included in the POST body when provided.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "x", } - client.add_memory("a", "u", "x", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER) + client.add_memory("a", "u", "x", metadata={"key": "val"}) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -250,10 +198,13 @@ def test_add_memory_excludes_none_optionals(self): """None-valued optional fields are omitted from the POST body.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) + client.add_memory("a", "u", "x") payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -263,10 +214,13 @@ def test_add_memory_posts_to_memories_endpoint(self): """add_memory sends the POST to the MEMORIES endpoint.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) + client.add_memory("a", "u", "x") call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORIES @@ -275,10 +229,13 @@ def test_get_memory_calls_get_with_memory_id(self): """get_memory constructs the correct path with the memory ID.""" client, mock_transport = _make_client() mock_transport.get.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "hello", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "hello", } - memory = client.get_memory("mem-1", access_strategy=AccessStrategy.PROVIDER) + memory = client.get_memory("mem-1") assert memory.id == "mem-1" call_path = mock_transport.get.call_args[0][0] @@ -288,7 +245,7 @@ def test_update_memory_calls_patch(self): """update_memory sends a PATCH with the updated fields.""" client, mock_transport = _make_client() - client.update_memory("mem-1", content="updated", access_strategy=AccessStrategy.PROVIDER) + client.update_memory("mem-1", content="updated") mock_transport.patch.assert_called_once() payload = mock_transport.patch.call_args[1]["json"] @@ -298,7 +255,7 @@ def test_update_memory_excludes_none_fields(self): """update_memory omits None-valued optional fields from the PATCH body.""" client, mock_transport = _make_client() - client.update_memory("mem-1", content="x", access_strategy=AccessStrategy.PROVIDER) + client.update_memory("mem-1", content="x") payload = mock_transport.patch.call_args[1]["json"] assert "metadata" not in payload @@ -307,7 +264,7 @@ def test_update_memory_with_metadata_only(self): """update_memory supports updating metadata without content.""" client, mock_transport = _make_client() - client.update_memory("mem-1", metadata={"key": "new-meta"}, access_strategy=AccessStrategy.PROVIDER) + client.update_memory("mem-1", metadata={"key": "new-meta"}) payload = mock_transport.patch.call_args[1]["json"] assert payload["metadata"] == {"key": "new-meta"} @@ -317,7 +274,7 @@ def test_delete_memory_calls_delete(self): """delete_memory sends a DELETE to the correct path.""" client, mock_transport = _make_client() - client.delete_memory("mem-1", access_strategy=AccessStrategy.PROVIDER) + client.delete_memory("mem-1") mock_transport.delete.assert_called_once() call_path = mock_transport.delete.call_args[0][0] @@ -328,7 +285,6 @@ def test_delete_memory_calls_delete(self): class TestListMemories: - def test_returns_list_of_memories(self): """list_memories returns a list of Memory objects.""" client, mock_transport = _make_client() @@ -338,7 +294,7 @@ def test_returns_list_of_memories(self): ], } - memories = client.list_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER) + memories = client.list_memories(agent_id="a", invoker_id="u") assert len(memories) == 1 assert isinstance(memories[0], Memory) @@ -348,7 +304,7 @@ def test_passes_filter_for_agent_and_invoker(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="agent-x", invoker_id="user-y", access_strategy=AccessStrategy.PROVIDER) + client.list_memories(agent_id="agent-x", invoker_id="user-y") params = mock_transport.get.call_args[1]["params"] assert "agentID eq 'agent-x'" in params["$filter"] @@ -359,7 +315,7 @@ def test_default_limit_is_50(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(access_strategy=AccessStrategy.PROVIDER) + client.list_memories() params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "50" @@ -369,7 +325,7 @@ def test_custom_limit(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(limit=5, access_strategy=AccessStrategy.PROVIDER) + client.list_memories(limit=5) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "5" @@ -379,7 +335,7 @@ def test_empty_list(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - memories = client.list_memories(access_strategy=AccessStrategy.PROVIDER) + memories = client.list_memories() assert len(memories) == 0 @@ -388,7 +344,7 @@ def test_offset_passes_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(offset=50, access_strategy=AccessStrategy.PROVIDER) + client.list_memories(offset=50) params = mock_transport.get.call_args[1]["params"] assert params["$skip"] == "50" @@ -398,7 +354,7 @@ def test_zero_offset_omits_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(access_strategy=AccessStrategy.PROVIDER) + client.list_memories() params = mock_transport.get.call_args[1]["params"] assert "$skip" not in params @@ -410,7 +366,6 @@ def test_filter_metadata_contains_adds_contains_clause(self): client.list_memories( filters=[FilterDefinition(target="metadata", contains="john")], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -423,7 +378,6 @@ def test_filter_content_contains_adds_contains_clause(self): client.list_memories( filters=[FilterDefinition(target="content", contains="dark mode")], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -439,7 +393,6 @@ def test_filter_multiple_clauses_joined_with_and(self): FilterDefinition(target="metadata", contains="john"), FilterDefinition(target="content", contains="user prefers"), ], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -457,7 +410,6 @@ def test_filter_combines_with_agent_and_invoker_filters(self): agent_id="my-agent", invoker_id="user-1", filters=[FilterDefinition(target="content", contains="dark mode")], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -471,20 +423,19 @@ def test_filter_none_does_not_change_behaviour(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_memories(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER) + client.list_memories(agent_id="a", invoker_id="u", filters=None) params = mock_transport.get.call_args[1]["params"] assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" class TestCountMemories: - def test_returns_count_from_response(self): """count_memories returns the @odata.count value.""" client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 42} - total = client.count_memories(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER) + total = client.count_memories(agent_id="a", invoker_id="u") assert total == 42 @@ -493,7 +444,7 @@ def test_sends_top_0_and_count_true(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 0} - client.count_memories(access_strategy=AccessStrategy.PROVIDER) + client.count_memories() params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "0" @@ -504,7 +455,7 @@ def test_passes_filter_when_agent_and_invoker_provided(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": [], "@odata.count": 3} - client.count_memories(agent_id="agt", invoker_id="usr", access_strategy=AccessStrategy.PROVIDER) + client.count_memories(agent_id="agt", invoker_id="usr") params = mock_transport.get.call_args[1]["params"] assert "agentID eq 'agt'" in params["$filter"] @@ -515,7 +466,7 @@ def test_returns_zero_when_count_missing(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - total = client.count_memories(access_strategy=AccessStrategy.PROVIDER) + total = client.count_memories() assert total == 0 @@ -524,18 +475,29 @@ def test_returns_zero_when_count_missing(self): class TestSearchMemories: - def test_returns_results_in_api_order(self): """search_memories returns results in the order returned by the API.""" client, mock_transport = _make_client() mock_transport.post.return_value = { "value": [ - {"id": "m1", "agentID": "a", "invokerID": "u", "content": "first", "similarity": 0.5}, - {"id": "m2", "agentID": "a", "invokerID": "u", "content": "second", "similarity": 0.9}, + { + "id": "m1", + "agentID": "a", + "invokerID": "u", + "content": "first", + "similarity": 0.5, + }, + { + "id": "m2", + "agentID": "a", + "invokerID": "u", + "content": "second", + "similarity": 0.9, + }, ] } - results = client.search_memories("a", "u", "test query", access_strategy=AccessStrategy.PROVIDER) + results = client.search_memories("a", "u", "test query") assert len(results) == 2 assert all(isinstance(r, SearchResult) for r in results) @@ -547,7 +509,7 @@ def test_posts_correct_payload(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5) call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORY_SEARCH @@ -563,7 +525,7 @@ def test_empty_results(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - results = client.search_memories("a", "u", "empty query", access_strategy=AccessStrategy.PROVIDER) + results = client.search_memories("a", "u", "empty query") assert len(results) == 0 @@ -572,7 +534,7 @@ def test_uses_default_threshold_and_limit(self): client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("a", "u", "query", access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "query") payload = mock_transport.post.call_args[1]["json"] assert payload["threshold"] == 0.6 @@ -584,7 +546,6 @@ def test_uses_default_threshold_and_limit(self): class TestMessageCRUD: - def test_add_message_posts_correct_payload(self): """add_message sends required fields in the POST body.""" client, mock_transport = _make_client() @@ -598,8 +559,11 @@ def test_add_message_posts_correct_payload(self): } message = client.add_message( - "agent-a", "user-b", "conv-1", MessageRole.USER, "Hello!", - access_strategy=AccessStrategy.PROVIDER, + "agent-a", + "user-b", + "conv-1", + MessageRole.USER, + "Hello!", ) assert isinstance(message, Message) @@ -616,11 +580,15 @@ def test_add_message_posts_to_messages_endpoint(self): """add_message sends the POST to the MESSAGES endpoint.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", } - client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) + client.add_message("a", "u", "g", MessageRole.USER, "hi") call_path = mock_transport.post.call_args[0][0] assert call_path == MESSAGES @@ -629,12 +597,18 @@ def test_add_message_with_metadata(self): """Optional metadata is included when provided.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", "metadata": {"key": "val"}, } - client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}, access_strategy=AccessStrategy.PROVIDER) + client.add_message( + "a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"} + ) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -643,11 +617,15 @@ def test_add_message_excludes_none_metadata(self): """None-valued metadata is omitted from the POST body.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", } - client.add_message("a", "u", "g", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) + client.add_message("a", "u", "g", MessageRole.USER, "hi") payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -656,11 +634,15 @@ def test_get_message_calls_get_with_message_id(self): """get_message constructs the correct path with the message ID.""" client, mock_transport = _make_client() mock_transport.get.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", } - message = client.get_message("msg-1", access_strategy=AccessStrategy.PROVIDER) + message = client.get_message("msg-1") assert message.id == "msg-1" call_path = mock_transport.get.call_args[0][0] @@ -670,7 +652,7 @@ def test_delete_message_calls_delete(self): """delete_message sends a DELETE to the correct path.""" client, mock_transport = _make_client() - client.delete_message("msg-1", access_strategy=AccessStrategy.PROVIDER) + client.delete_message("msg-1") mock_transport.delete.assert_called_once() call_path = mock_transport.delete.call_args[0][0] @@ -681,20 +663,23 @@ def test_delete_message_calls_delete(self): class TestListMessages: - def test_returns_list_of_messages(self): """list_messages returns a list of Message objects.""" client, mock_transport = _make_client() mock_transport.get.return_value = { "value": [ { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", }, ], } - messages = client.list_messages(agent_id="a", invoker_id="u", access_strategy=AccessStrategy.PROVIDER) + messages = client.list_messages(agent_id="a", invoker_id="u") assert len(messages) == 1 assert isinstance(messages[0], Message) @@ -705,9 +690,10 @@ def test_passes_convenience_filters(self): mock_transport.get.return_value = {"value": []} client.list_messages( - agent_id="a", invoker_id="u", - message_group="conv-1", role="USER", - access_strategy=AccessStrategy.PROVIDER, + agent_id="a", + invoker_id="u", + message_group="conv-1", + role="USER", ) params = mock_transport.get.call_args[1]["params"] @@ -722,7 +708,7 @@ def test_default_limit_is_50(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(access_strategy=AccessStrategy.PROVIDER) + client.list_messages() params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "50" @@ -732,7 +718,7 @@ def test_custom_limit(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(limit=20, access_strategy=AccessStrategy.PROVIDER) + client.list_messages(limit=20) params = mock_transport.get.call_args[1]["params"] assert params["$top"] == "20" @@ -742,7 +728,7 @@ def test_empty_list(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - messages = client.list_messages(access_strategy=AccessStrategy.PROVIDER) + messages = client.list_messages() assert len(messages) == 0 @@ -751,7 +737,7 @@ def test_offset_passes_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(offset=100, access_strategy=AccessStrategy.PROVIDER) + client.list_messages(offset=100) params = mock_transport.get.call_args[1]["params"] assert params["$skip"] == "100" @@ -761,7 +747,7 @@ def test_zero_offset_omits_skip_param(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(access_strategy=AccessStrategy.PROVIDER) + client.list_messages() params = mock_transport.get.call_args[1]["params"] assert "$skip" not in params @@ -773,7 +759,6 @@ def test_filter_metadata_contains_adds_contains_clause(self): client.list_messages( filters=[FilterDefinition(target="metadata", contains="demo-app")], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -786,7 +771,6 @@ def test_filter_content_contains_adds_contains_clause(self): client.list_messages( filters=[FilterDefinition(target="content", contains="invoice")], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -802,7 +786,6 @@ def test_filter_multiple_clauses_joined_with_and(self): FilterDefinition(target="metadata", contains="john"), FilterDefinition(target="content", contains="user prefers"), ], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -822,7 +805,6 @@ def test_filter_combines_with_convenience_filters(self): message_group="g", role="USER", filters=[FilterDefinition(target="content", contains="hello")], - access_strategy=AccessStrategy.PROVIDER, ) params = mock_transport.get.call_args[1]["params"] @@ -838,7 +820,7 @@ def test_filter_none_does_not_change_behaviour(self): client, mock_transport = _make_client() mock_transport.get.return_value = {"value": []} - client.list_messages(agent_id="a", invoker_id="u", filters=None, access_strategy=AccessStrategy.PROVIDER) + client.list_messages(agent_id="a", invoker_id="u", filters=None) params = mock_transport.get.call_args[1]["params"] assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -848,18 +830,19 @@ def test_filter_none_does_not_change_behaviour(self): class TestRetentionConfig: - def test_get_retention_config(self): """get_retention_config sends GET to the retentionConfig endpoint.""" client, mock_transport = _make_client() mock_transport.get.return_value = { - "id": 1, "messageDays": 30, "memoryDays": 90, + "id": 1, + "messageDays": 30, + "memoryDays": 90, "usageLogDays": 180, "createTimestamp": "2025-01-01T00:00:00Z", "updateTimestamp": "2025-01-02T00:00:00Z", } - rc = client.get_retention_config(access_strategy=AccessStrategy.PROVIDER) + rc = client.get_retention_config() assert isinstance(rc, RetentionConfig) assert rc.id == 1 @@ -873,7 +856,7 @@ def test_update_retention_config(self): """update_retention_config sends PATCH with updated fields.""" client, mock_transport = _make_client() - client.update_retention_config(message_days=60, access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config(message_days=60) mock_transport.patch.assert_called_once() call_path = mock_transport.patch.call_args[0][0] @@ -886,7 +869,7 @@ def test_update_retention_config_excludes_none_fields(self): """update_retention_config omits None-valued fields from PATCH body.""" client, mock_transport = _make_client() - client.update_retention_config(memory_days=90, usage_log_days=180, access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config(memory_days=90, usage_log_days=180) payload = mock_transport.patch.call_args[1]["json"] assert "messageDays" not in payload @@ -898,7 +881,6 @@ def test_update_retention_config_excludes_none_fields(self): class TestContextManager: - def test_close_delegates_to_transport(self): """close() delegates to the transport's close method.""" client, mock_transport = _make_client() @@ -912,215 +894,206 @@ def test_context_manager_closes_on_exit(self): transport = MagicMock(spec=HttpTransport) client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) - with client: - pass - - transport.close.assert_called_once() - # ── Validation ──────────────────────────────────────────────────────────────── class TestMemoryValidation: - def test_add_memory_raises_for_empty_agent_id(self): """add_memory raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.add_memory("", "user-1", "content", access_strategy=AccessStrategy.PROVIDER) + client.add_memory("", "user-1", "content") def test_add_memory_raises_for_empty_invoker_id(self): """add_memory raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.add_memory("agent-1", "", "content", access_strategy=AccessStrategy.PROVIDER) + client.add_memory("agent-1", "", "content") def test_add_memory_raises_for_empty_content(self): """add_memory raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): - client.add_memory("agent-1", "user-1", "", access_strategy=AccessStrategy.PROVIDER) + client.add_memory("agent-1", "user-1", "") def test_get_memory_raises_for_empty_id(self): """get_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.get_memory("", access_strategy=AccessStrategy.PROVIDER) + client.get_memory("") def test_update_memory_raises_for_empty_id(self): """update_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.update_memory("", content="new content", access_strategy=AccessStrategy.PROVIDER) + client.update_memory("", content="new content") def test_update_memory_raises_when_no_fields_provided(self): """update_memory raises AgentMemoryValidationError when neither content nor metadata is provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): - client.update_memory("uuid-123", access_strategy=AccessStrategy.PROVIDER) + client.update_memory("uuid-123") def test_delete_memory_raises_for_empty_id(self): """delete_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): - client.delete_memory("", access_strategy=AccessStrategy.PROVIDER) + client.delete_memory("") def test_list_memories_raises_for_zero_limit(self): """list_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.list_memories(limit=0, access_strategy=AccessStrategy.PROVIDER) + client.list_memories(limit=0) def test_list_memories_raises_for_negative_offset(self): """list_memories raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): - client.list_memories(offset=-1, access_strategy=AccessStrategy.PROVIDER) + client.list_memories(offset=-1) class TestSearchMemoriesValidation: - def test_raises_for_empty_agent_id(self): """search_memories raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.search_memories("", "user-1", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER) + client.search_memories("", "user-1", "what do I know about Python?") def test_raises_for_empty_invoker_id(self): """search_memories raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.search_memories("agent-1", "", "what do I know about Python?", access_strategy=AccessStrategy.PROVIDER) + client.search_memories("agent-1", "", "what do I know about Python?") def test_raises_for_query_too_short(self): """search_memories raises AgentMemoryValidationError when query has fewer than 5 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): - client.search_memories("agent-1", "user-1", "hi", access_strategy=AccessStrategy.PROVIDER) + client.search_memories("agent-1", "user-1", "hi") def test_raises_for_query_too_long(self): """search_memories raises AgentMemoryValidationError when query exceeds 5000 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): - client.search_memories("agent-1", "user-1", "x" * 5001, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("agent-1", "user-1", "x" * 5001) def test_raises_for_threshold_below_zero(self): """search_memories raises AgentMemoryValidationError when threshold < 0.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): - client.search_memories("a", "u", "valid query here", threshold=-0.1, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "valid query here", threshold=-0.1) def test_raises_for_threshold_above_one(self): """search_memories raises AgentMemoryValidationError when threshold > 1.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): - client.search_memories("a", "u", "valid query here", threshold=1.1, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "valid query here", threshold=1.1) def test_raises_for_limit_zero(self): """search_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.search_memories("a", "u", "valid query here", limit=0, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "valid query here", limit=0) def test_raises_for_limit_above_fifty(self): """search_memories raises AgentMemoryValidationError when limit exceeds 50.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.search_memories("a", "u", "valid query here", limit=51, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "valid query here", limit=51) def test_boundary_values_are_accepted(self): """search_memories accepts boundary values: 5-char query, threshold 0.0/1.0, limit 1/50.""" client, mock_transport = _make_client() mock_transport.post.return_value = {"value": []} - client.search_memories("a", "u", "hello", threshold=0.0, limit=1, access_strategy=AccessStrategy.PROVIDER) - client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50, access_strategy=AccessStrategy.PROVIDER) + client.search_memories("a", "u", "hello", threshold=0.0, limit=1) + client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50) assert mock_transport.post.call_count == 2 class TestMessageValidation: - def test_add_message_raises_for_empty_agent_id(self): """add_message raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): - client.add_message("", "u", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) + client.add_message("", "u", "grp", MessageRole.USER, "hi") def test_add_message_raises_for_empty_invoker_id(self): """add_message raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): - client.add_message("a", "", "grp", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) + client.add_message("a", "", "grp", MessageRole.USER, "hi") def test_add_message_raises_for_empty_message_group(self): """add_message raises AgentMemoryValidationError when message_group is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_group"): - client.add_message("a", "u", "", MessageRole.USER, "hi", access_strategy=AccessStrategy.PROVIDER) + client.add_message("a", "u", "", MessageRole.USER, "hi") def test_add_message_raises_for_empty_content(self): """add_message raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): - client.add_message("a", "u", "grp", MessageRole.USER, "", access_strategy=AccessStrategy.PROVIDER) + client.add_message("a", "u", "grp", MessageRole.USER, "") def test_get_message_raises_for_empty_id(self): """get_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): - client.get_message("", access_strategy=AccessStrategy.PROVIDER) + client.get_message("") def test_delete_message_raises_for_empty_id(self): """delete_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): - client.delete_message("", access_strategy=AccessStrategy.PROVIDER) + client.delete_message("") def test_list_messages_raises_for_zero_limit(self): """list_messages raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): - client.list_messages(limit=0, access_strategy=AccessStrategy.PROVIDER) + client.list_messages(limit=0) def test_list_messages_raises_for_negative_offset(self): """list_messages raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): - client.list_messages(offset=-1, access_strategy=AccessStrategy.PROVIDER) + client.list_messages(offset=-1) class TestRetentionConfigValidation: - def test_update_raises_when_no_fields_provided(self): """update_retention_config raises AgentMemoryValidationError when no fields are provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): - client.update_retention_config(access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config() def test_update_raises_for_negative_message_days(self): """update_retention_config raises AgentMemoryValidationError when message_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_days"): - client.update_retention_config(message_days=-1, access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config(message_days=-1) def test_update_raises_for_negative_memory_days(self): """update_retention_config raises AgentMemoryValidationError when memory_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_days"): - client.update_retention_config(memory_days=-1, access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config(memory_days=-1) def test_update_raises_for_negative_usage_log_days(self): """update_retention_config raises AgentMemoryValidationError when usage_log_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="usage_log_days"): - client.update_retention_config(usage_log_days=-1, access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config(usage_log_days=-1) def test_update_accepts_zero_values(self): """update_retention_config accepts 0 as a valid value (disables cleanup).""" client, mock_transport = _make_client() - client.update_retention_config(memory_days=0, access_strategy=AccessStrategy.PROVIDER) + client.update_retention_config(memory_days=0) mock_transport.patch.assert_called_once() @@ -1129,14 +1102,12 @@ def test_update_accepts_zero_values(self): class TestFilterDefinitionValidation: - def test_list_memories_raises_for_unsupported_target(self): """list_memories raises AgentMemoryValidationError for an unknown target.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="target"): client.list_memories( filters=[FilterDefinition(target="agentID", contains="x")], - access_strategy=AccessStrategy.PROVIDER, ) def test_list_memories_raises_for_empty_contains(self): @@ -1145,7 +1116,6 @@ def test_list_memories_raises_for_empty_contains(self): with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_memories( filters=[FilterDefinition(target="content", contains="")], - access_strategy=AccessStrategy.PROVIDER, ) def test_list_messages_raises_for_unsupported_target(self): @@ -1154,7 +1124,6 @@ def test_list_messages_raises_for_unsupported_target(self): with pytest.raises(AgentMemoryValidationError, match="target"): client.list_messages( filters=[FilterDefinition(target="role", contains="x")], - access_strategy=AccessStrategy.PROVIDER, ) def test_list_messages_raises_for_empty_contains(self): @@ -1163,5 +1132,4 @@ def test_list_messages_raises_for_empty_contains(self): with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_messages( filters=[FilterDefinition(target="metadata", contains="")], - access_strategy=AccessStrategy.PROVIDER, )