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/9] 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/9] 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/9] 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/9] 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 40a149d58c46432c10b547dc08707841b81059d6 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Wed, 15 Jul 2026 15:32:46 -0300 Subject: [PATCH 5/9] feat(agent-memory): implement multitenancy via per-tenant bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _load_config_for_instance(instance) to config.py — loads secrets from /etc/secrets/appfnd/hana-agent-memory/{instance}/ following the same pattern as the destination module - Refactor AgentMemoryClient to maintain a per-tenant HttpTransport cache: provider binding ('default') and subscriber bindings (one per tenant) are each loaded into their own transport and lazily cached - create_client() selects the initial binding based on access_strategy and tenant, and wires config_loader for per-call tenant override support - Integration test conftest now skips subscriber tests when the tenant binding itself is not configured (not just the tenant name env var) - Unit tests cover _load_config_for_instance, _get_transport routing, per-call binding cache, and create_client() factory wiring --- src/sap_cloud_sdk/agent_memory/__init__.py | 41 +++- src/sap_cloud_sdk/agent_memory/client.py | 162 +++++++------ src/sap_cloud_sdk/agent_memory/config.py | 33 ++- tests/agent_memory/integration/conftest.py | 39 ++- tests/agent_memory/unit/test_client.py | 269 ++++++++++++--------- tests/agent_memory/unit/test_config.py | 77 +++++- 6 files changed, 409 insertions(+), 212 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 8d5b4099..644d846d 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -19,7 +19,11 @@ from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory.client import AgentMemoryClient -from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_config_from_env +from sap_cloud_sdk.agent_memory.config import ( + AgentMemoryConfig, + _load_config_for_instance, + _load_config_from_env, +) from sap_cloud_sdk.agent_memory.exceptions import ( AgentMemoryConfigError, AgentMemoryError, @@ -46,11 +50,21 @@ def create_client( ) -> AgentMemoryClient: """Create an :class:`AgentMemoryClient` with automatic credential detection. + The binding loaded depends on ``access_strategy`` and ``tenant``: + + - ``SUBSCRIBER_ONLY`` with ``tenant="acme-corp"`` — loads the subscriber + binding from ``/etc/secrets/appfnd/hana-agent-memory/acme-corp/`` (or + ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). Per-call + tenant overrides load additional bindings lazily and cache them. + - ``PROVIDER_ONLY`` — loads the provider binding from + ``/etc/secrets/appfnd/hana-agent-memory/default/`` (or + ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars). + - Explicit ``config`` — uses the provided configuration for all calls. + Per-call tenant overrides are not supported when ``config`` is provided. + Args: - config: Optional explicit configuration. If ``None``, credentials are - loaded from the mounted volume at - ``/etc/secrets/appfnd/hana-agent-memory/default/`` or from - ``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables. + config: Optional explicit configuration. When provided, no binding + discovery is performed and per-call tenant overrides are disabled. access_strategy: Default tenant access strategy for all client operations. Defaults to ``SUBSCRIBER_ONLY``. Individual method calls may override this value. @@ -62,16 +76,25 @@ def create_client( A ready-to-use :class:`AgentMemoryClient`. Raises: - AgentMemoryConfigError: If configuration is missing, invalid, or - ``access_strategy=SUBSCRIBER_ONLY`` is used without a ``tenant``. + AgentMemoryConfigError: If configuration is missing or invalid. """ try: - resolved_config = config if config is not None else _load_config_from_env() - transport = HttpTransport(resolved_config) + if config is not None: + initial_config = config + loader = None + elif access_strategy is AccessStrategy.SUBSCRIBER_ONLY and tenant: + initial_config = _load_config_for_instance(tenant) + loader = _load_config_for_instance + else: + initial_config = _load_config_from_env() + loader = _load_config_for_instance + + transport = HttpTransport(initial_config) return AgentMemoryClient( transport, access_strategy=access_strategy, tenant=tenant, + config_loader=loader, ) except AgentMemoryConfigError: raise diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index 53b0dcdb..1b3fbb0b 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -9,7 +9,10 @@ from __future__ import annotations -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Callable, Optional + +if TYPE_CHECKING: + from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig from sap_cloud_sdk.agent_memory._endpoints import ( MEMORIES, @@ -34,6 +37,7 @@ extract_value_and_count, ) from sap_cloud_sdk.agent_memory.exceptions import ( + AgentMemoryConfigError, AgentMemoryValidationError, ) from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics @@ -74,11 +78,15 @@ class AgentMemoryClient: Do not instantiate directly — use :func:`sap_cloud_sdk.agent_memory.create_client`. Args: - transport: HTTP transport layer (injected by ``create_client``). + transport: HTTP transport loaded from the provider (``default``) binding, + or from the subscriber binding when ``tenant`` is provided at init time. 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. + config_loader: Callable that loads an ``AgentMemoryConfig`` for a named + binding instance (e.g. ``_load_config_for_instance``). Required to + support per-call tenant overrides that differ from the client default. """ def __init__( @@ -87,14 +95,26 @@ def __init__( *, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, tenant: Optional[str] = None, + config_loader: Optional[Callable[[str], "AgentMemoryConfig"]] = None, ) -> None: - self._transport = transport self._default_access_strategy = access_strategy self._default_tenant = tenant + self._config_loader = config_loader + self._subscriber_transport_cache: dict[str, HttpTransport] = {} + if access_strategy is AccessStrategy.SUBSCRIBER_ONLY and tenant: + # Pre-warm cache with the already-loaded subscriber transport + self._subscriber_transport_cache[tenant] = transport + self._provider_transport: Optional[HttpTransport] = None + else: + self._provider_transport = transport def close(self) -> None: - """Close the underlying HTTP session and release resources.""" - self._transport.close() + """Close all underlying HTTP sessions and release resources.""" + if self._provider_transport is not None: + self._provider_transport.close() + for t in self._subscriber_transport_cache.values(): + t.close() + self._subscriber_transport_cache.clear() def __enter__(self) -> AgentMemoryClient: return self @@ -102,18 +122,22 @@ def __enter__(self) -> AgentMemoryClient: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() - def _resolve_tenant( + def _get_transport( self, access_strategy: Optional[AccessStrategy], tenant: Optional[str], - ) -> Optional[str]: - """Return the tenant subdomain to use for token derivation. + ) -> HttpTransport: + """Return the transport for the effective access strategy and tenant. - Per-call parameters take precedence over instance defaults. + Per-call parameters take precedence over instance defaults. Each + subscriber tenant gets its own transport loaded from its binding + (lazily, with caching). The provider always uses a single transport. Raises: - AgentMemoryValidationError: If the effective strategy is ``SUBSCRIBER_ONLY`` - but no tenant is available. + AgentMemoryValidationError: If the effective strategy is + ``SUBSCRIBER_ONLY`` but no tenant is available. + AgentMemoryConfigError: If a per-call subscriber tenant requires a + new binding load but no ``config_loader`` is configured. """ effective_strategy = ( access_strategy @@ -122,18 +146,34 @@ 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 - ): - raise AgentMemoryValidationError( - "tenant is required when access_strategy=SUBSCRIBER_ONLY" - ) - return ( - effective_tenant - if effective_strategy is AccessStrategy.SUBSCRIBER_ONLY - else None - ) + if effective_strategy is AccessStrategy.SUBSCRIBER_ONLY: + if not effective_tenant: + raise AgentMemoryValidationError( + "tenant is required when access_strategy=SUBSCRIBER_ONLY" + ) + if effective_tenant not in self._subscriber_transport_cache: + if self._config_loader is None: + raise AgentMemoryConfigError( + f"No config_loader available to load binding for tenant " + f"'{effective_tenant}'. Pass config_loader to create_client()." + ) + config = self._config_loader(effective_tenant) + self._subscriber_transport_cache[effective_tenant] = HttpTransport( + config + ) + return self._subscriber_transport_cache[effective_tenant] + + # PROVIDER_ONLY + if self._provider_transport is None: + if self._config_loader is None: + raise AgentMemoryConfigError( + "No provider transport available. " + "This client was created with a subscriber binding only." + ) + from sap_cloud_sdk.agent_memory.config import _load_config_from_env + + self._provider_transport = HttpTransport(_load_config_from_env()) + return self._provider_transport # ── Memory operations ────────────────────────────────────────────────────── @@ -169,7 +209,7 @@ 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) + transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -177,9 +217,7 @@ def add_memory( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post( - MEMORIES, json=payload, tenant_subdomain=tenant_subdomain - ) + data = transport.post(MEMORIES, json=payload) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MEMORY) @@ -209,10 +247,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 - ) + transport = self._get_transport(access_strategy, tenant) + data = transport.get(f"{MEMORIES}({memory_id})") return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_MEMORY) @@ -247,15 +283,13 @@ def update_memory( raise AgentMemoryValidationError( "At least one of 'content' or 'metadata' must be provided" ) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + transport = self._get_transport(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 - ) + transport.patch(f"{MEMORIES}({memory_id})", json=payload) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MEMORY) def delete_memory( @@ -281,10 +315,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 - ) + transport = self._get_transport(access_strategy, tenant) + transport.delete(f"{MEMORIES}({memory_id})") @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MEMORIES) def list_memories( @@ -329,7 +361,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) + transport = self._get_transport(access_strategy, tenant) params = build_list_params( filter_expr=build_memory_filter( agent_id=agent_id, @@ -339,9 +371,7 @@ def list_memories( top=limit, skip=offset if offset else None, ) - response = self._transport.get( - MEMORIES, params=params, tenant_subdomain=tenant_subdomain - ) + response = transport.get(MEMORIES, params=params) items, _ = extract_value_and_count(response) return [Memory.from_dict(item) for item in items] @@ -371,15 +401,13 @@ def count_memories( AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + transport = self._get_transport(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 - ) + response = transport.get(MEMORIES, params=params) _, total = extract_value_and_count(response) return total or 0 @@ -426,7 +454,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) + transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -434,9 +462,7 @@ def search_memories( "threshold": threshold, "top": limit, } - response = self._transport.post( - MEMORY_SEARCH, json=payload, tenant_subdomain=tenant_subdomain - ) + response = transport.post(MEMORY_SEARCH, json=payload) items = response.get("value", []) return [SearchResult.from_dict(item) for item in items] @@ -486,7 +512,7 @@ def add_message( message_group=message_group, content=content, ) - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) + transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -496,9 +522,7 @@ def add_message( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post( - MESSAGES, json=payload, tenant_subdomain=tenant_subdomain - ) + data = transport.post(MESSAGES, json=payload) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MESSAGE) @@ -528,10 +552,8 @@ 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 - ) + transport = self._get_transport(access_strategy, tenant) + data = transport.get(f"{MESSAGES}({message_id})") return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MESSAGE) @@ -558,10 +580,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 - ) + transport = self._get_transport(access_strategy, tenant) + transport.delete(f"{MESSAGES}({message_id})") @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MESSAGES) def list_messages( @@ -610,7 +630,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) + transport = self._get_transport(access_strategy, tenant) params = build_list_params( filter_expr=build_message_filter( agent_id=agent_id, @@ -622,9 +642,7 @@ def list_messages( top=limit, skip=offset if offset else None, ) - response = self._transport.get( - MESSAGES, params=params, tenant_subdomain=tenant_subdomain - ) + response = transport.get(MESSAGES, params=params) items, _ = extract_value_and_count(response) return [Message.from_dict(item) for item in items] @@ -652,8 +670,8 @@ def get_retention_config( AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER_ONLY``. AgentMemoryHttpError: If the request fails. """ - tenant_subdomain = self._resolve_tenant(access_strategy, tenant) - data = self._transport.get(RETENTION_CONFIG, tenant_subdomain=tenant_subdomain) + transport = self._get_transport(access_strategy, tenant) + data = transport.get(RETENTION_CONFIG) return RetentionConfig.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_RETENTION_CONFIG) @@ -698,7 +716,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) + transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = {} if message_days is not None: payload["messageDays"] = message_days @@ -706,6 +724,4 @@ 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, tenant_subdomain=tenant_subdomain - ) + transport.patch(RETENTION_CONFIG, json=payload) diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index a32afb70..16083f85 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -5,8 +5,10 @@ Mount path convention:: - /etc/secrets/appfnd/hana-agent-memory/default/application_url + /etc/secrets/appfnd/hana-agent-memory/default/application_url (provider) + /etc/secrets/appfnd/hana-agent-memory//application_url (subscriber) /etc/secrets/appfnd/hana-agent-memory/default/uaa + /etc/secrets/appfnd/hana-agent-memory//uaa ``application_url`` is the Agent Memory service base URL (plain string). ``uaa`` is a JSON string with OAuth2 credentials containing at minimum: @@ -16,6 +18,8 @@ CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA """ import json @@ -130,6 +134,29 @@ def _load_config_from_env() -> AgentMemoryConfig: 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/default/`` 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` + Returns: + A validated ``AgentMemoryConfig``. + + Raises: + AgentMemoryConfigError: If configuration cannot be loaded or is incomplete. + """ + return _load_config_for_instance("default") + + +def _load_config_for_instance(instance: str) -> AgentMemoryConfig: + """Load Agent Memory configuration for a named binding instance. + + Uses the secret resolver with fallback order: + 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/{instance}/`` + 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_{INSTANCE}_*`` + + This is used to load tenant-specific bindings when the runtime provisions + a dedicated service instance per subscriber tenant. + + Args: + instance: The binding instance name — ``"default"`` for the provider, + or a tenant subdomain for a subscriber (e.g. ``"acme-corp"``). + Returns: A validated ``AgentMemoryConfig``. @@ -146,7 +173,7 @@ def _load_config_from_env() -> AgentMemoryConfig: base_volume_mount="/etc/secrets/appfnd", base_var_name="CLOUD_SDK_CFG", module="hana-agent-memory", - instance="default", + instance=instance, target=binding, ) binding.validate() @@ -155,5 +182,5 @@ def _load_config_from_env() -> AgentMemoryConfig: raise except Exception as exc: raise AgentMemoryConfigError( - f"Failed to load Agent Memory configuration: {exc}" + f"Failed to load Agent Memory configuration for instance '{instance}': {exc}" ) from exc diff --git a/tests/agent_memory/integration/conftest.py b/tests/agent_memory/integration/conftest.py index 0eecd02b..32749b39 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -2,12 +2,21 @@ Set the following environment variables before running integration tests: - CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_URL Base URL of the Agent Memory service - 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 +Provider (default) binding: -Multitenancy: + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA + +Subscriber binding (one set per tenant, keyed by subdomain in upper-snake-case): + + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA + + e.g. for tenant "acme-corp": + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA + +Subscriber tenant name: CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT Subscriber tenant subdomain Required for SUBSCRIBER_ONLY tests. When absent those tests are skipped. @@ -45,7 +54,16 @@ def agent_memory_client() -> AgentMemoryClient: @pytest.fixture(scope="session") def subscriber_tenant() -> str: - """Return the subscriber tenant subdomain, or skip if not configured.""" + """Return the subscriber tenant subdomain, or skip if not configured. + + On this branch, a separate binding must exist for the tenant subdomain: + /etc/secrets/appfnd/hana-agent-memory// + or environment variables: + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA + """ + from sap_cloud_sdk.agent_memory.config import _load_config_for_instance + env_file = Path(__file__).parents[3] / ".env_integration_tests" if env_file.exists(): load_dotenv(env_file, override=True) @@ -56,4 +74,13 @@ def subscriber_tenant() -> str: "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT not set — " "skipping subscriber tenant tests" ) + + try: + _load_config_for_instance(tenant) + except AgentMemoryConfigError: + pytest.skip( + f"Subscriber binding for tenant '{tenant}' not configured — " + f"skipping subscriber tenant tests" + ) + return tenant diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index 515b0af0..67a30489 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -21,32 +21,50 @@ from sap_cloud_sdk.agent_memory.client import AgentMemoryClient from sap_cloud_sdk.agent_memory import create_client, FilterDefinition from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig -from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError +from sap_cloud_sdk.agent_memory.exceptions import ( + AgentMemoryConfigError, + AgentMemoryValidationError, +) + def _make_client() -> tuple[AgentMemoryClient, MagicMock]: - """Return an AgentMemoryClient with a mocked transport and PROVIDER_ONLY default.""" + """Return an AgentMemoryClient with a mocked provider transport.""" transport = MagicMock(spec=HttpTransport) client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) return client, transport +def _make_subscriber_client( + tenant: str = "default-sub", +) -> tuple[AgentMemoryClient, MagicMock]: + """Return a client with SUBSCRIBER_ONLY default and a pre-warmed mock transport.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient( + transport, + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + 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_ONLY strategy.""" + """Factory with explicit config and PROVIDER_ONLY stores the transport as provider.""" 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) assert isinstance(client, AgentMemoryClient) assert client._default_access_strategy is AccessStrategy.PROVIDER_ONLY - assert client._default_tenant is None + assert client._provider_transport is not None + assert client._config_loader is None # explicit config disables lazy loading def test_uses_provided_config_with_subscriber_strategy(self): - """Factory stores subscriber strategy and tenant on the client.""" + """Factory with explicit config and SUBSCRIBER_ONLY pre-warms subscriber cache.""" config = AgentMemoryConfig(base_url="http://localhost:3000") with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) @@ -57,157 +75,168 @@ def test_uses_provided_config_with_subscriber_strategy(self): ) assert client._default_access_strategy is AccessStrategy.SUBSCRIBER_ONLY assert client._default_tenant == "acme-corp" + assert "acme-corp" in client._subscriber_transport_cache + assert client._config_loader is None + + def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): + """Factory with SUBSCRIBER_ONLY loads the tenant binding (not default).""" + import json + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", + "http://acme.memory.example.com", + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", + json.dumps({"url": "http://acme.auth.example.com", "clientid": "c", "clientsecret": "s"}), + ) + with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: + MockTransport.return_value = MagicMock(spec=HttpTransport) + client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + tenant="acme-corp", + ) + # config_loader is set so per-call overrides work + assert client._config_loader is not None + assert "acme-corp" in client._subscriber_transport_cache - def test_reads_env_when_no_config_provided(self, monkeypatch): - """Factory falls back to environment variables when no config given.""" + def test_provider_strategy_loads_default_binding(self, monkeypatch): + """Factory with PROVIDER_ONLY loads the default binding.""" 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": "c", "clientsecret": "s"}), + ) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) client = create_client(access_strategy=AccessStrategy.PROVIDER_ONLY) - assert isinstance(client, AgentMemoryClient) + assert client._provider_transport is not None + assert client._config_loader is not None + +# ── Access strategy and per-tenant transport routing ───────────────────────── -# ── Access strategy ─────────────────────────────────────────────────────────── class TestAccessStrategy: - 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) + # ── _get_transport routing ───────────────────────────────────────────────── - # ── _resolve_tenant instance method ─────────────────────────────────────── + def test_provider_default_returns_provider_transport(self): + """PROVIDER_ONLY default returns the provider transport.""" + client, transport = _make_client() + assert client._get_transport(None, None) is transport - 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_returns_pre_warmed_transport(self): + """SUBSCRIBER_ONLY default returns the pre-warmed subscriber transport.""" + client, transport = _make_subscriber_client("acme") + assert client._get_transport(None, None) is transport - def test_subscriber_default_no_tenant_raises_at_call_time(self): - """SUBSCRIBER_ONLY default with no tenant raises at first call.""" + def test_subscriber_default_no_tenant_raises(self): + """SUBSCRIBER_ONLY default without tenant raises at call time.""" transport = MagicMock(spec=HttpTransport) client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER_ONLY) 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" + client._get_transport(None, None) 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 + """Per-call PROVIDER_ONLY overrides SUBSCRIBER_ONLY default → provider transport.""" + client, sub_transport = _make_subscriber_client("acme") + provider_transport = MagicMock(spec=HttpTransport) + client._provider_transport = provider_transport + assert client._get_transport(AccessStrategy.PROVIDER_ONLY, None) is provider_transport 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"): - client._resolve_tenant(AccessStrategy.SUBSCRIBER_ONLY, None) - - # ── Client-level defaults flow through to transport ─────────────────────── + """Per-call SUBSCRIBER_ONLY overrides PROVIDER_ONLY default → loads/caches subscriber.""" + client, provider_transport = _make_client() + sub_transport = MagicMock(spec=HttpTransport) + mock_loader = MagicMock( + return_value=AgentMemoryConfig(base_url="http://sub.example.com") + ) + client._config_loader = mock_loader + with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): + result = client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + assert result is sub_transport + assert "beta" in client._subscriber_transport_cache + mock_loader.assert_called_once_with("beta") + + def test_per_call_subscriber_cached_on_second_call(self): + """Subscriber binding is loaded once and cached for subsequent calls.""" + client, _ = _make_client() + sub_transport = MagicMock(spec=HttpTransport) + mock_loader = MagicMock( + return_value=AgentMemoryConfig(base_url="http://sub.example.com") + ) + client._config_loader = mock_loader + with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): + client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + mock_loader.assert_called_once() # loaded only once + + def test_per_call_subscriber_without_config_loader_raises(self): + """Per-call subscriber with no config_loader raises AgentMemoryConfigError.""" + client, _ = _make_client() + assert client._config_loader is None + with pytest.raises(AgentMemoryConfigError, match="config_loader"): + client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + + def test_per_call_tenant_overrides_default_tenant(self): + """Per-call tenant overrides the default subscriber tenant.""" + client, _ = _make_subscriber_client("acme") + override_transport = MagicMock(spec=HttpTransport) + mock_loader = MagicMock( + return_value=AgentMemoryConfig(base_url="http://beta.example.com") + ) + client._config_loader = mock_loader + with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=override_transport): + result = client._get_transport(None, "beta") + assert result is override_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") + # ── Method-level integration ─────────────────────────────────────────────── + def test_client_default_subscriber_uses_subscriber_transport(self): + """Client with SUBSCRIBER_ONLY default routes calls to the subscriber transport.""" + client, sub_transport = _make_subscriber_client("acme") + sub_transport.post.return_value = { + "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + } client.add_memory("a", "u", "x") + sub_transport.post.assert_called_once() - 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") - - assert transport.post.call_args[1]["tenant_subdomain"] == "override-sub" - - # ── 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.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + def test_per_call_subscriber_uses_different_transport_than_provider(self): + """Per-call subscriber tenant uses a different transport from the provider.""" + client, provider_transport = _make_client() + sub_transport = MagicMock(spec=HttpTransport) + sub_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", + mock_loader = MagicMock( + return_value=AgentMemoryConfig(base_url="http://sub.example.com") ) - - 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 = { + client._config_loader = mock_loader + with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="acme") + sub_transport.post.assert_called_once() + provider_transport.post.assert_not_called() + + def test_provider_only_uses_provider_transport(self): + """PROVIDER_ONLY routes calls to the provider transport.""" + client, provider_transport = _make_client() + provider_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 + provider_transport.post.assert_called_once() 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 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 - ) - + 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 ──────────────────────────────────────────────────── diff --git a/tests/agent_memory/unit/test_config.py b/tests/agent_memory/unit/test_config.py index 40e9b2ea..ce297514 100644 --- a/tests/agent_memory/unit/test_config.py +++ b/tests/agent_memory/unit/test_config.py @@ -1,4 +1,4 @@ -"""Unit tests for AgentMemoryConfig, BindingData, and _load_config_from_env.""" +"""Unit tests for AgentMemoryConfig, BindingData, _load_config_from_env, and _load_config_for_instance.""" import json from unittest.mock import patch @@ -8,6 +8,7 @@ from sap_cloud_sdk.agent_memory.config import ( AgentMemoryConfig, BindingData, + _load_config_for_instance, _load_config_from_env, ) from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @@ -210,3 +211,77 @@ def test_raises_config_error_when_uaa_json_invalid(self, monkeypatch): with patch("os.stat", side_effect=FileNotFoundError("no mount")): with pytest.raises(AgentMemoryConfigError, match="Failed to parse uaa JSON"): _load_config_from_env() + + +# ── _load_config_for_instance ───────────────────────────────────────────────── + + +def _fill_binding_for_instance(instance_name: str): + """Return a side_effect that fills binding only when instance matches.""" + def _fill(**kwargs): + assert kwargs["instance"] == instance_name + kwargs["target"].application_url = f"https://{instance_name}.memory.example.com" + kwargs["target"].uaa = json.dumps({ + "url": f"https://{instance_name}.auth.example.com", + "clientid": f"{instance_name}-client", + "clientsecret": "secret", + }) + return _fill + + +class TestLoadConfigForInstance: + + def test_loads_named_instance_binding(self): + """Loads config from the specified instance name (not 'default').""" + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("acme-corp")): + config = _load_config_for_instance("acme-corp") + + assert config.base_url == "https://acme-corp.memory.example.com" + assert config.token_url == "https://acme-corp.auth.example.com/oauth/token" + assert config.client_id == "acme-corp-client" + + def test_calls_resolver_with_correct_instance(self): + """Resolver receives the exact instance name passed (not 'default').""" + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("beta-tenant")) as mock_resolver: + _load_config_for_instance("beta-tenant") + + _, kwargs = mock_resolver.call_args + assert kwargs["module"] == "hana-agent-memory" + assert kwargs["instance"] == "beta-tenant" + + def test_default_instance_is_equivalent_to_load_config_from_env(self): + """_load_config_for_instance('default') produces the same result as _load_config_from_env.""" + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("default")): + config_instance = _load_config_for_instance("default") + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("default")): + config_env = _load_config_from_env() + + assert config_instance.base_url == config_env.base_url + assert config_instance.token_url == config_env.token_url + + def test_raises_with_instance_name_in_message_when_binding_missing(self): + """Error message includes the instance name when the binding cannot be loaded.""" + with patch(_RESOLVER, side_effect=RuntimeError("secrets not found")): + with pytest.raises(AgentMemoryConfigError, match="acme-corp"): + _load_config_for_instance("acme-corp") + + def test_loads_from_env_vars_for_named_instance(self, monkeypatch): + """Subscriber binding loaded from env vars keyed by tenant name.""" + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", + "https://acme-corp.memory.example.com", + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", + json.dumps({ + "url": "https://acme-corp.auth.example.com", + "clientid": "acme-client", + "clientsecret": "secret", + }), + ) + + with patch("os.stat", side_effect=FileNotFoundError("no mount")): + config = _load_config_for_instance("acme-corp") + + assert config.base_url == "https://acme-corp.memory.example.com" + assert config.client_id == "acme-client" From c3a275762b0deab3724f00f674b8dbc3c3577e9f Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Wed, 15 Jul 2026 17:41:20 -0300 Subject: [PATCH 6/9] 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 | 14 +- src/sap_cloud_sdk/agent_memory/_models.py | 4 +- src/sap_cloud_sdk/agent_memory/client.py | 75 +++--- 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 | 234 +++++++++--------- 9 files changed, 206 insertions(+), 199 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 644d846d..5074056f 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") @@ -45,18 +45,18 @@ 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. The binding loaded depends on ``access_strategy`` and ``tenant``: - - ``SUBSCRIBER_ONLY`` with ``tenant="acme-corp"`` — loads the subscriber + - ``SUBSCRIBER`` with ``tenant="acme-corp"`` — loads the subscriber binding from ``/etc/secrets/appfnd/hana-agent-memory/acme-corp/`` (or ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). Per-call tenant overrides load additional bindings lazily and cache them. - - ``PROVIDER_ONLY`` — loads the provider binding from + - ``PROVIDER`` — loads the provider binding from ``/etc/secrets/appfnd/hana-agent-memory/default/`` (or ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars). - Explicit ``config`` — uses the provided configuration for all calls. @@ -66,10 +66,10 @@ def create_client( config: Optional explicit configuration. When provided, no binding discovery is performed and per-call tenant overrides are disabled. 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: @@ -82,7 +82,7 @@ def create_client( if config is not None: initial_config = config loader = None - elif access_strategy is AccessStrategy.SUBSCRIBER_ONLY and tenant: + elif access_strategy is AccessStrategy.SUBSCRIBER and tenant: initial_config = _load_config_for_instance(tenant) loader = _load_config_for_instance else: 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 1b3fbb0b..57a78116 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 TYPE_CHECKING, Any, Callable, Optional if TYPE_CHECKING: @@ -42,6 +43,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.""" @@ -81,9 +84,9 @@ class AgentMemoryClient: transport: HTTP transport loaded from the provider (``default``) binding, or from the subscriber binding when ``tenant`` is provided at init time. 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. config_loader: Callable that loads an ``AgentMemoryConfig`` for a named binding instance (e.g. ``_load_config_for_instance``). Required to support per-call tenant overrides that differ from the client default. @@ -93,7 +96,7 @@ def __init__( self, transport: HttpTransport, *, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_ONLY, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, tenant: Optional[str] = None, config_loader: Optional[Callable[[str], "AgentMemoryConfig"]] = None, ) -> None: @@ -101,7 +104,7 @@ def __init__( self._default_tenant = tenant self._config_loader = config_loader self._subscriber_transport_cache: dict[str, HttpTransport] = {} - if access_strategy is AccessStrategy.SUBSCRIBER_ONLY and tenant: + if access_strategy is AccessStrategy.SUBSCRIBER and tenant: # Pre-warm cache with the already-loaded subscriber transport self._subscriber_transport_cache[tenant] = transport self._provider_transport: Optional[HttpTransport] = None @@ -135,7 +138,7 @@ def _get_transport( Raises: AgentMemoryValidationError: If the effective strategy is - ``SUBSCRIBER_ONLY`` but no tenant is available. + ``SUBSCRIBER`` but no tenant is available. AgentMemoryConfigError: If a per-call subscriber tenant requires a new binding load but no ``config_loader`` is configured. """ @@ -146,10 +149,10 @@ def _get_transport( ) effective_tenant = tenant if tenant is not None else self._default_tenant - if effective_strategy is AccessStrategy.SUBSCRIBER_ONLY: + if effective_strategy is AccessStrategy.SUBSCRIBER: if not effective_tenant: raise AgentMemoryValidationError( - "tenant is required when access_strategy=SUBSCRIBER_ONLY" + "tenant is required when access_strategy=SUBSCRIBER" ) if effective_tenant not in self._subscriber_transport_cache: if self._config_loader is None: @@ -163,7 +166,11 @@ def _get_transport( ) return self._subscriber_transport_cache[effective_tenant] - # PROVIDER_ONLY + # PROVIDER + logger.warning( + "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " + "Only use this strategy for provider-owned operations." + ) if self._provider_transport is None: if self._config_loader is None: raise AgentMemoryConfigError( @@ -198,14 +205,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) @@ -235,7 +242,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`. @@ -243,7 +250,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) @@ -270,12 +277,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) @@ -306,12 +313,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) @@ -345,14 +352,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: @@ -392,13 +399,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. """ transport = self._get_transport(access_strategy, tenant) @@ -434,7 +441,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. @@ -442,7 +449,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) @@ -496,14 +503,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( @@ -540,7 +547,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`. @@ -548,7 +555,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) @@ -571,12 +578,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) @@ -614,14 +621,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: @@ -661,13 +668,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. """ transport = self._get_transport(access_strategy, tenant) @@ -697,11 +704,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 16083f85..37569002 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -40,7 +40,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 32749b39..5bc95b69 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -19,7 +19,7 @@ Subscriber tenant name: 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 @@ -37,15 +37,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 67a30489..94c43b2a 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -30,18 +30,18 @@ def _make_client() -> tuple[AgentMemoryClient, MagicMock]: """Return an AgentMemoryClient with a mocked provider transport.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER_ONLY) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) return client, transport def _make_subscriber_client( tenant: str = "default-sub", ) -> tuple[AgentMemoryClient, MagicMock]: - """Return a client with SUBSCRIBER_ONLY default and a pre-warmed mock transport.""" + """Return a client with SUBSCRIBER default and a pre-warmed mock transport.""" transport = MagicMock(spec=HttpTransport) client = AgentMemoryClient( transport, - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant=tenant, ) return client, transport @@ -53,33 +53,33 @@ def _make_subscriber_client( class TestCreateClient: def test_uses_provided_config_with_provider_strategy(self): - """Factory with explicit config and PROVIDER_ONLY stores the transport as provider.""" + """Factory with explicit config and PROVIDER stores the transport as provider.""" 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._provider_transport is not None assert client._config_loader is None # explicit config disables lazy loading def test_uses_provided_config_with_subscriber_strategy(self): - """Factory with explicit config and SUBSCRIBER_ONLY pre-warms subscriber cache.""" + """Factory with explicit config and SUBSCRIBER pre-warms subscriber cache.""" 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, + 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" assert "acme-corp" in client._subscriber_transport_cache assert client._config_loader is None def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): - """Factory with SUBSCRIBER_ONLY loads the tenant binding (not default).""" + """Factory with SUBSCRIBER loads the tenant binding (not default).""" import json monkeypatch.setenv( "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", @@ -92,7 +92,7 @@ def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) client = create_client( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, + access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", ) # config_loader is set so per-call overrides work @@ -100,7 +100,7 @@ def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): assert "acme-corp" in client._subscriber_transport_cache def test_provider_strategy_loads_default_binding(self, monkeypatch): - """Factory with PROVIDER_ONLY loads the default binding.""" + """Factory with PROVIDER loads the default binding.""" import json monkeypatch.setenv( "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", @@ -112,7 +112,7 @@ def test_provider_strategy_loads_default_binding(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 client._provider_transport is not None assert client._config_loader is not None @@ -125,31 +125,31 @@ class TestAccessStrategy: # ── _get_transport routing ───────────────────────────────────────────────── def test_provider_default_returns_provider_transport(self): - """PROVIDER_ONLY default returns the provider transport.""" + """PROVIDER default returns the provider transport.""" client, transport = _make_client() assert client._get_transport(None, None) is transport def test_subscriber_default_returns_pre_warmed_transport(self): - """SUBSCRIBER_ONLY default returns the pre-warmed subscriber transport.""" + """SUBSCRIBER default returns the pre-warmed subscriber transport.""" client, transport = _make_subscriber_client("acme") assert client._get_transport(None, None) is transport def test_subscriber_default_no_tenant_raises(self): - """SUBSCRIBER_ONLY default without tenant raises at call time.""" + """SUBSCRIBER default without tenant raises at call time.""" 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._get_transport(None, None) def test_per_call_provider_overrides_subscriber_default(self): - """Per-call PROVIDER_ONLY overrides SUBSCRIBER_ONLY default → provider transport.""" + """Per-call PROVIDER overrides SUBSCRIBER default → provider transport.""" client, sub_transport = _make_subscriber_client("acme") provider_transport = MagicMock(spec=HttpTransport) client._provider_transport = provider_transport - assert client._get_transport(AccessStrategy.PROVIDER_ONLY, None) is provider_transport + assert client._get_transport(AccessStrategy.PROVIDER, None) is provider_transport def test_per_call_subscriber_overrides_provider_default(self): - """Per-call SUBSCRIBER_ONLY overrides PROVIDER_ONLY default → loads/caches subscriber.""" + """Per-call SUBSCRIBER overrides PROVIDER default → loads/caches subscriber.""" client, provider_transport = _make_client() sub_transport = MagicMock(spec=HttpTransport) mock_loader = MagicMock( @@ -157,7 +157,7 @@ def test_per_call_subscriber_overrides_provider_default(self): ) client._config_loader = mock_loader with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): - result = client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + result = client._get_transport(AccessStrategy.SUBSCRIBER, "beta") assert result is sub_transport assert "beta" in client._subscriber_transport_cache mock_loader.assert_called_once_with("beta") @@ -171,8 +171,8 @@ def test_per_call_subscriber_cached_on_second_call(self): ) client._config_loader = mock_loader with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): - client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") - client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + client._get_transport(AccessStrategy.SUBSCRIBER, "beta") + client._get_transport(AccessStrategy.SUBSCRIBER, "beta") mock_loader.assert_called_once() # loaded only once def test_per_call_subscriber_without_config_loader_raises(self): @@ -180,7 +180,7 @@ def test_per_call_subscriber_without_config_loader_raises(self): client, _ = _make_client() assert client._config_loader is None with pytest.raises(AgentMemoryConfigError, match="config_loader"): - client._get_transport(AccessStrategy.SUBSCRIBER_ONLY, "beta") + client._get_transport(AccessStrategy.SUBSCRIBER, "beta") def test_per_call_tenant_overrides_default_tenant(self): """Per-call tenant overrides the default subscriber tenant.""" @@ -197,7 +197,7 @@ def test_per_call_tenant_overrides_default_tenant(self): # ── Method-level integration ─────────────────────────────────────────────── def test_client_default_subscriber_uses_subscriber_transport(self): - """Client with SUBSCRIBER_ONLY default routes calls to the subscriber transport.""" + """Client with SUBSCRIBER default routes calls to the subscriber transport.""" client, sub_transport = _make_subscriber_client("acme") sub_transport.post.return_value = { "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", @@ -217,24 +217,24 @@ def test_per_call_subscriber_uses_different_transport_than_provider(self): ) client._config_loader = mock_loader with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="acme") + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme") sub_transport.post.assert_called_once() provider_transport.post.assert_not_called() def test_provider_only_uses_provider_transport(self): - """PROVIDER_ONLY routes calls to the provider transport.""" + """PROVIDER routes calls to the provider transport.""" client, provider_transport = _make_client() provider_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) provider_transport.post.assert_called_once() 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 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) + client.add_memory("a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER) mock_transport.post.assert_not_called() @@ -254,7 +254,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" @@ -270,7 +270,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"} @@ -282,7 +282,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 @@ -295,7 +295,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 @@ -307,7 +307,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] @@ -317,7 +317,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"] @@ -327,7 +327,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 @@ -336,7 +336,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"} @@ -346,7 +346,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] @@ -367,7 +367,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) @@ -377,7 +377,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"] @@ -388,7 +388,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" @@ -398,7 +398,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" @@ -408,7 +408,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 @@ -417,7 +417,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" @@ -427,7 +427,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 @@ -439,7 +439,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"] @@ -452,7 +452,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"] @@ -468,7 +468,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"] @@ -486,7 +486,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"] @@ -500,7 +500,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'" @@ -513,7 +513,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 @@ -522,7 +522,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" @@ -533,7 +533,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"] @@ -544,7 +544,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 @@ -564,7 +564,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) @@ -576,7 +576,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 @@ -592,7 +592,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 @@ -601,7 +601,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 @@ -628,7 +628,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) @@ -649,7 +649,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 @@ -663,7 +663,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"} @@ -676,7 +676,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 @@ -689,7 +689,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] @@ -699,7 +699,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] @@ -723,7 +723,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) @@ -736,7 +736,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"] @@ -751,7 +751,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" @@ -761,7 +761,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" @@ -771,7 +771,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 @@ -780,7 +780,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" @@ -790,7 +790,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 @@ -802,7 +802,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"] @@ -815,7 +815,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"] @@ -831,7 +831,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"] @@ -851,7 +851,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"] @@ -867,7 +867,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'" @@ -888,7 +888,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 @@ -902,7 +902,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] @@ -915,7 +915,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 @@ -939,7 +939,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 @@ -956,55 +956,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: @@ -1013,57 +1013,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 @@ -1074,49 +1074,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: @@ -1125,31 +1125,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() @@ -1165,7 +1165,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): @@ -1174,7 +1174,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): @@ -1183,7 +1183,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): @@ -1192,5 +1192,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 e7885abd4ac666a556374aed0eacbda623c76d87 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Thu, 16 Jul 2026 13:32:11 -0300 Subject: [PATCH 7/9] chore: bump version to 0.36.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a9e4a46a..9a8cf7a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.36.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From b80686b5b03638e7efaa158ccb392f6eab1f676d Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Thu, 16 Jul 2026 14:54:37 -0300 Subject: [PATCH 8/9] feat(agent-memory): keep tenant and access_strategy only in the client creation --- src/sap_cloud_sdk/agent_memory/__init__.py | 34 +- src/sap_cloud_sdk/agent_memory/client.py | 279 +++------------- src/sap_cloud_sdk/agent_memory/user-guide.md | 40 +-- .../integration/test_agentmemory_bdd.py | 44 --- tests/agent_memory/unit/test_client.py | 299 ++++++------------ uv.lock | 12 +- 6 files changed, 170 insertions(+), 538 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 5074056f..6177df68 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -54,47 +54,41 @@ def create_client( - ``SUBSCRIBER`` with ``tenant="acme-corp"`` — loads the subscriber binding from ``/etc/secrets/appfnd/hana-agent-memory/acme-corp/`` (or - ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). Per-call - tenant overrides load additional bindings lazily and cache them. + ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). - ``PROVIDER`` — loads the provider binding from ``/etc/secrets/appfnd/hana-agent-memory/default/`` (or ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars). - - Explicit ``config`` — uses the provided configuration for all calls. - Per-call tenant overrides are not supported when ``config`` is provided. + - Explicit ``config`` — uses the provided configuration directly. Args: - config: Optional explicit configuration. When provided, no binding - discovery is performed and per-call tenant overrides are disabled. - access_strategy: Default tenant access strategy for all client operations. - Defaults to ``SUBSCRIBER``. Individual method calls may override - this value. - tenant: Default subscriber tenant subdomain. Required when - ``access_strategy=SUBSCRIBER``. Individual method calls may - override this value. + config: Optional explicit configuration. When provided, binding + discovery is skipped. + access_strategy: Tenant access strategy for all operations. + Defaults to ``SUBSCRIBER``. + tenant: Subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER``. Returns: A ready-to-use :class:`AgentMemoryClient`. Raises: AgentMemoryConfigError: If configuration is missing or invalid. + AgentMemoryValidationError: If ``access_strategy=SUBSCRIBER`` and + ``tenant`` is not provided. """ try: if config is not None: - initial_config = config - loader = None + resolved_config = config elif access_strategy is AccessStrategy.SUBSCRIBER and tenant: - initial_config = _load_config_for_instance(tenant) - loader = _load_config_for_instance + resolved_config = _load_config_for_instance(tenant) else: - initial_config = _load_config_from_env() - loader = _load_config_for_instance + resolved_config = _load_config_from_env() - transport = HttpTransport(initial_config) + transport = HttpTransport(resolved_config) return AgentMemoryClient( transport, access_strategy=access_strategy, tenant=tenant, - config_loader=loader, ) except AgentMemoryConfigError: raise diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index 57a78116..d715ffdd 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -10,10 +10,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Callable, Optional - -if TYPE_CHECKING: - from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig +from typing import Any, Optional from sap_cloud_sdk.agent_memory._endpoints import ( MEMORIES, @@ -81,15 +78,13 @@ class AgentMemoryClient: Do not instantiate directly — use :func:`sap_cloud_sdk.agent_memory.create_client`. Args: - transport: HTTP transport loaded from the provider (``default``) binding, - or from the subscriber binding when ``tenant`` is provided at init time. - 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. - config_loader: Callable that loads an ``AgentMemoryConfig`` for a named - binding instance (e.g. ``_load_config_for_instance``). Required to - support per-call tenant overrides that differ from the client default. + transport: HTTP transport loaded from the binding for the configured + access strategy and tenant (resolved once at construction time by + :func:`sap_cloud_sdk.agent_memory.create_client`). + access_strategy: Tenant access strategy for all operations. + Defaults to ``SUBSCRIBER``. + tenant: Subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER``. """ def __init__( @@ -98,26 +93,21 @@ def __init__( *, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, tenant: Optional[str] = None, - config_loader: Optional[Callable[[str], "AgentMemoryConfig"]] = None, ) -> None: - self._default_access_strategy = access_strategy - self._default_tenant = tenant - self._config_loader = config_loader - self._subscriber_transport_cache: dict[str, HttpTransport] = {} - if access_strategy is AccessStrategy.SUBSCRIBER and tenant: - # Pre-warm cache with the already-loaded subscriber transport - self._subscriber_transport_cache[tenant] = transport - self._provider_transport: Optional[HttpTransport] = None - else: - self._provider_transport = transport + 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 def close(self) -> None: - """Close all underlying HTTP sessions and release resources.""" - if self._provider_transport is not None: - self._provider_transport.close() - for t in self._subscriber_transport_cache.values(): - t.close() - self._subscriber_transport_cache.clear() + """Close the underlying HTTP session and release resources.""" + self._transport.close() def __enter__(self) -> AgentMemoryClient: return self @@ -125,63 +115,6 @@ def __enter__(self) -> AgentMemoryClient: def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() - def _get_transport( - self, - access_strategy: Optional[AccessStrategy], - tenant: Optional[str], - ) -> HttpTransport: - """Return the transport for the effective access strategy and tenant. - - Per-call parameters take precedence over instance defaults. Each - subscriber tenant gets its own transport loaded from its binding - (lazily, with caching). The provider always uses a single transport. - - Raises: - AgentMemoryValidationError: If the effective strategy is - ``SUBSCRIBER`` but no tenant is available. - AgentMemoryConfigError: If a per-call subscriber tenant requires a - new binding load but no ``config_loader`` is configured. - """ - 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: - if not effective_tenant: - raise AgentMemoryValidationError( - "tenant is required when access_strategy=SUBSCRIBER" - ) - if effective_tenant not in self._subscriber_transport_cache: - if self._config_loader is None: - raise AgentMemoryConfigError( - f"No config_loader available to load binding for tenant " - f"'{effective_tenant}'. Pass config_loader to create_client()." - ) - config = self._config_loader(effective_tenant) - self._subscriber_transport_cache[effective_tenant] = HttpTransport( - config - ) - return self._subscriber_transport_cache[effective_tenant] - - # PROVIDER - logger.warning( - "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " - "Only use this strategy for provider-owned operations." - ) - if self._provider_transport is None: - if self._config_loader is None: - raise AgentMemoryConfigError( - "No provider transport available. " - "This client was created with a subscriber binding only." - ) - from sap_cloud_sdk.agent_memory.config import _load_config_from_env - - self._provider_transport = HttpTransport(_load_config_from_env()) - return self._provider_transport - # ── Memory operations ────────────────────────────────────────────────────── @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_ADD_MEMORY) @@ -192,8 +125,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. @@ -202,21 +133,15 @@ 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. 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``. Returns: The created :class:`Memory`. Raises: - AgentMemoryValidationError: If any required field is empty or tenant is missing - for ``SUBSCRIBER``. + AgentMemoryValidationError: If any required field is empty. AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, content=content) - transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -224,38 +149,26 @@ def add_memory( } if metadata is not None: payload["metadata"] = metadata - data = transport.post(MEMORIES, json=payload) + data = self._transport.post(MEMORIES, json=payload) 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: memory_id: The memory identifier (UUID). - 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``. Returns: The :class:`Memory`. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. - AgentMemoryValidationError: If ``memory_id`` is empty or tenant is missing - for ``SUBSCRIBER``. + AgentMemoryValidationError: If ``memory_id`` is empty. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - transport = self._get_transport(access_strategy, tenant) - data = transport.get(f"{MEMORIES}({memory_id})") + data = self._transport.get(f"{MEMORIES}({memory_id})") return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_MEMORY) @@ -265,8 +178,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. @@ -274,10 +185,6 @@ 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. 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``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. @@ -290,40 +197,27 @@ def update_memory( raise AgentMemoryValidationError( "At least one of 'content' or 'metadata' must be provided" ) - transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = {} if content is not None: payload["content"] = content if metadata is not None: payload["metadata"] = metadata - transport.patch(f"{MEMORIES}({memory_id})", json=payload) + self._transport.patch(f"{MEMORIES}({memory_id})", json=payload) @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: memory_id: The memory identifier (UUID). - 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``. Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. - AgentMemoryValidationError: If ``memory_id`` is empty or tenant is missing - for ``SUBSCRIBER``. + AgentMemoryValidationError: If ``memory_id`` is empty. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - transport = self._get_transport(access_strategy, tenant) - transport.delete(f"{MEMORIES}({memory_id})") + self._transport.delete(f"{MEMORIES}({memory_id})") @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MEMORIES) def list_memories( @@ -334,8 +228,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. @@ -349,10 +241,6 @@ 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. 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``. Returns: List of :class:`Memory` objects. @@ -368,7 +256,6 @@ def list_memories( raise AgentMemoryValidationError("'offset' must be >= 0") if filters is not None: _validate_filter_clauses(filters, {"metadata", "content"}) - transport = self._get_transport(access_strategy, tenant) params = build_list_params( filter_expr=build_memory_filter( agent_id=agent_id, @@ -378,43 +265,32 @@ def list_memories( top=limit, skip=offset if offset else None, ) - response = transport.get(MEMORIES, params=params) + response = self._transport.get(MEMORIES, params=params) 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. Args: agent_id: Filter by agent identifier. invoker_id: Filter by invoker/user identifier. - 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``. Returns: Total number of matching memories. Raises: - AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ - transport = self._get_transport(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 = transport.get(MEMORIES, params=params) + response = self._transport.get(MEMORIES, params=params) _, total = extract_value_and_count(response) return total or 0 @@ -426,9 +302,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. @@ -438,10 +311,6 @@ 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. 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``. Returns: List of :class:`SearchResult` objects. @@ -461,7 +330,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") - transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -469,7 +337,7 @@ def search_memories( "threshold": threshold, "top": limit, } - response = transport.post(MEMORY_SEARCH, json=payload) + response = self._transport.post(MEMORY_SEARCH, json=payload) items = response.get("value", []) return [SearchResult.from_dict(item) for item in items] @@ -485,8 +353,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. @@ -500,17 +366,12 @@ def add_message( role: Author role (USER, ASSISTANT, SYSTEM, TOOL). content: The message text content. metadata: Optional metadata dict. - 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``. Returns: The created :class:`Message`. Raises: - AgentMemoryValidationError: If any required field is empty or tenant is missing - for ``SUBSCRIBER``. + AgentMemoryValidationError: If any required field is empty. AgentMemoryHttpError: If the request fails. """ _require_non_empty( @@ -519,7 +380,6 @@ def add_message( message_group=message_group, content=content, ) - transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = { "agentID": agent_id, "invokerID": invoker_id, @@ -529,66 +389,42 @@ def add_message( } if metadata is not None: payload["metadata"] = metadata - data = transport.post(MESSAGES, json=payload) + data = self._transport.post(MESSAGES, json=payload) 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: message_id: The message identifier (UUID). - 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``. Returns: The :class:`Message`. Raises: AgentMemoryNotFoundError: If no message with the given ID exists. - AgentMemoryValidationError: If ``message_id`` is empty or tenant is missing - for ``SUBSCRIBER``. + AgentMemoryValidationError: If ``message_id`` is empty. AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - transport = self._get_transport(access_strategy, tenant) - data = transport.get(f"{MESSAGES}({message_id})") + data = self._transport.get(f"{MESSAGES}({message_id})") 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: message_id: The message identifier (UUID). - 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``. Raises: AgentMemoryNotFoundError: If no message with the given ID exists. - AgentMemoryValidationError: If ``message_id`` is empty or tenant is missing - for ``SUBSCRIBER``. + AgentMemoryValidationError: If ``message_id`` is empty. AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - transport = self._get_transport(access_strategy, tenant) - transport.delete(f"{MESSAGES}({message_id})") + self._transport.delete(f"{MESSAGES}({message_id})") @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MESSAGES) def list_messages( @@ -601,8 +437,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. @@ -618,10 +452,6 @@ 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. 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``. Returns: List of :class:`Message` objects. @@ -637,7 +467,6 @@ def list_messages( raise AgentMemoryValidationError("'offset' must be >= 0") if filters is not None: _validate_filter_clauses(filters, {"metadata", "content"}) - transport = self._get_transport(access_strategy, tenant) params = build_list_params( filter_expr=build_message_filter( agent_id=agent_id, @@ -649,36 +478,25 @@ def list_messages( top=limit, skip=offset if offset else None, ) - response = transport.get(MESSAGES, params=params) + response = self._transport.get(MESSAGES, params=params) 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, - *, - access_strategy: Optional[AccessStrategy] = None, - tenant: Optional[str] = None, - ) -> RetentionConfig: + def get_retention_config(self) -> RetentionConfig: """Retrieve the data retention configuration (singleton). Args: - 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``. Returns: The current :class:`RetentionConfig`. Raises: - AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ - transport = self._get_transport(access_strategy, tenant) - data = transport.get(RETENTION_CONFIG) + data = self._transport.get(RETENTION_CONFIG) return RetentionConfig.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_RETENTION_CONFIG) @@ -688,8 +506,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. @@ -701,10 +517,6 @@ 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. 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``. Raises: AgentMemoryValidationError: If no fields are provided, any provided value is @@ -723,7 +535,6 @@ def update_retention_config( ): if value is not None and value < 0: raise AgentMemoryValidationError(f"'{name}' must be >= 0") - transport = self._get_transport(access_strategy, tenant) payload: dict[str, Any] = {} if message_days is not None: payload["messageDays"] = message_days @@ -731,4 +542,4 @@ def update_retention_config( payload["memoryDays"] = memory_days if usage_log_days is not None: payload["usageLogDays"] = usage_log_days - transport.patch(RETENTION_CONFIG, json=payload) + self._transport.patch(RETENTION_CONFIG, json=payload) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 8d78423b..3f611771 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -25,8 +25,8 @@ plain text, and the service makes it searchable by meaning. - [Multitenancy](#multitenancy) - [AccessStrategy](#accessstrategy) - [Configuring at client level](#configuring-at-client-level) - - [SUBSCRIBER\_ONLY (default)](#subscriber_only-default) - - [PROVIDER\_ONLY](#provider_only) + - [SUBSCRIBER (default)](#subscriber-default) + - [PROVIDER](#provider) - [Semantic Search: A Brief Primer](#semantic-search-a-brief-primer) - [Memories](#memories) - [Create a Memory](#create-a-memory) @@ -72,6 +72,7 @@ plain text, and the service makes it searchable by meaning. - [Usage with LangGraph StateGraph](#usage-with-langgraph-stategraph) - [Usage with LangChain create\_agent](#usage-with-langchain-create_agent) - [Thread TTL](#thread-ttl) + - [Exposing TTL as a configurable parameter with `@agent_config`](#exposing-ttl-as-a-configurable-parameter-with-agent_config) ## Installation @@ -206,46 +207,25 @@ 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 / "acme-corp" except this one -provider_memories = client.list_memories( - agent_id="hr-assistant", - invoker_id="user-42", - access_strategy=AccessStrategy.PROVIDER, # overrides for this call only -) -``` - ### SUBSCRIBER (default) -Pass the subscriber tenant subdomain via the `tenant` argument. Omitting `tenant` when -the strategy is `SUBSCRIBER` raises `AgentMemoryValidationError`. +Configure a subscriber tenant at client creation. All calls will use that tenant context. ```python -memories = client.list_memories( - agent_id="hr-assistant", - invoker_id="user-42", +client = create_client( access_strategy=AccessStrategy.SUBSCRIBER, - tenant="acme-corp", # subscriber subdomain + tenant="acme-corp", ) +memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") ``` -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 -No `tenant` argument is needed. All calls use the provider token. +Configure a provider-only client. No tenant is needed; all calls use the provider binding. ```python -memories = client.list_memories( - agent_id="hr-assistant", - invoker_id="user-42", - access_strategy=AccessStrategy.PROVIDER, -) +client = create_client(access_strategy=AccessStrategy.PROVIDER) +memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") ``` > [!WARNING] 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 94c43b2a..959ed315 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -52,34 +52,17 @@ def _make_subscriber_client( class TestCreateClient: - def test_uses_provided_config_with_provider_strategy(self): - """Factory with explicit config and PROVIDER stores the transport as provider.""" + def test_uses_provided_config(self): + """Factory with explicit config creates a client 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) assert isinstance(client, AgentMemoryClient) - assert client._default_access_strategy is AccessStrategy.PROVIDER - assert client._provider_transport is not None - assert client._config_loader is None # explicit config disables lazy loading - - def test_uses_provided_config_with_subscriber_strategy(self): - """Factory with explicit config and SUBSCRIBER pre-warms subscriber cache.""" - 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, - tenant="acme-corp", - ) - assert client._default_access_strategy is AccessStrategy.SUBSCRIBER - assert client._default_tenant == "acme-corp" - assert "acme-corp" in client._subscriber_transport_cache - assert client._config_loader is None + assert client._transport is not None def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): - """Factory with SUBSCRIBER loads the tenant binding (not default).""" + """Factory with SUBSCRIBER loads the tenant binding.""" import json monkeypatch.setenv( "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", @@ -95,9 +78,8 @@ def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", ) - # config_loader is set so per-call overrides work - assert client._config_loader is not None - assert "acme-corp" in client._subscriber_transport_cache + assert isinstance(client, AgentMemoryClient) + assert client._transport is not None def test_provider_strategy_loads_default_binding(self, monkeypatch): """Factory with PROVIDER loads the default binding.""" @@ -113,8 +95,8 @@ def test_provider_strategy_loads_default_binding(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) - assert client._provider_transport is not None - assert client._config_loader is not None + assert isinstance(client, AgentMemoryClient) + assert client._transport is not None # ── Access strategy and per-tenant transport routing ───────────────────────── @@ -122,82 +104,28 @@ def test_provider_strategy_loads_default_binding(self, monkeypatch): class TestAccessStrategy: - # ── _get_transport routing ───────────────────────────────────────────────── - - def test_provider_default_returns_provider_transport(self): - """PROVIDER default returns the provider transport.""" - client, transport = _make_client() - assert client._get_transport(None, None) is transport + # ── Construction-time validation ─────────────────────────────────────────── - def test_subscriber_default_returns_pre_warmed_transport(self): - """SUBSCRIBER default returns the pre-warmed subscriber transport.""" - client, transport = _make_subscriber_client("acme") - assert client._get_transport(None, None) is transport - - def test_subscriber_default_no_tenant_raises(self): - """SUBSCRIBER default without tenant raises at call time.""" + def test_subscriber_without_tenant_raises_at_construction(self): + """SUBSCRIBER without tenant raises AgentMemoryValidationError at construction.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) with pytest.raises(AgentMemoryValidationError, match="tenant"): - client._get_transport(None, None) + AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) - def test_per_call_provider_overrides_subscriber_default(self): - """Per-call PROVIDER overrides SUBSCRIBER default → provider transport.""" - client, sub_transport = _make_subscriber_client("acme") - provider_transport = MagicMock(spec=HttpTransport) - client._provider_transport = provider_transport - assert client._get_transport(AccessStrategy.PROVIDER, None) is provider_transport + def test_subscriber_with_tenant_constructs_successfully(self): + """SUBSCRIBER with tenant constructs without error.""" + client, _ = _make_subscriber_client("acme") + assert client._transport is not None - def test_per_call_subscriber_overrides_provider_default(self): - """Per-call SUBSCRIBER overrides PROVIDER default → loads/caches subscriber.""" - client, provider_transport = _make_client() - sub_transport = MagicMock(spec=HttpTransport) - mock_loader = MagicMock( - return_value=AgentMemoryConfig(base_url="http://sub.example.com") - ) - client._config_loader = mock_loader - with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): - result = client._get_transport(AccessStrategy.SUBSCRIBER, "beta") - assert result is sub_transport - assert "beta" in client._subscriber_transport_cache - mock_loader.assert_called_once_with("beta") - - def test_per_call_subscriber_cached_on_second_call(self): - """Subscriber binding is loaded once and cached for subsequent calls.""" - client, _ = _make_client() - sub_transport = MagicMock(spec=HttpTransport) - mock_loader = MagicMock( - return_value=AgentMemoryConfig(base_url="http://sub.example.com") - ) - client._config_loader = mock_loader - with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): - client._get_transport(AccessStrategy.SUBSCRIBER, "beta") - client._get_transport(AccessStrategy.SUBSCRIBER, "beta") - mock_loader.assert_called_once() # loaded only once - - def test_per_call_subscriber_without_config_loader_raises(self): - """Per-call subscriber with no config_loader raises AgentMemoryConfigError.""" + def test_provider_constructs_without_tenant(self): + """PROVIDER constructs without tenant.""" client, _ = _make_client() - assert client._config_loader is None - with pytest.raises(AgentMemoryConfigError, match="config_loader"): - client._get_transport(AccessStrategy.SUBSCRIBER, "beta") + assert client._transport is not None - def test_per_call_tenant_overrides_default_tenant(self): - """Per-call tenant overrides the default subscriber tenant.""" - client, _ = _make_subscriber_client("acme") - override_transport = MagicMock(spec=HttpTransport) - mock_loader = MagicMock( - return_value=AgentMemoryConfig(base_url="http://beta.example.com") - ) - client._config_loader = mock_loader - with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=override_transport): - result = client._get_transport(None, "beta") - assert result is override_transport - - # ── Method-level integration ─────────────────────────────────────────────── + # ── Transport routing ────────────────────────────────────────────────────── def test_client_default_subscriber_uses_subscriber_transport(self): - """Client with SUBSCRIBER default routes calls to the subscriber transport.""" + """Client with SUBSCRIBER default uses the provided transport.""" client, sub_transport = _make_subscriber_client("acme") sub_transport.post.return_value = { "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", @@ -205,38 +133,15 @@ def test_client_default_subscriber_uses_subscriber_transport(self): client.add_memory("a", "u", "x") sub_transport.post.assert_called_once() - def test_per_call_subscriber_uses_different_transport_than_provider(self): - """Per-call subscriber tenant uses a different transport from the provider.""" - client, provider_transport = _make_client() - sub_transport = MagicMock(spec=HttpTransport) - sub_transport.post.return_value = { - "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", - } - mock_loader = MagicMock( - return_value=AgentMemoryConfig(base_url="http://sub.example.com") - ) - client._config_loader = mock_loader - with patch("sap_cloud_sdk.agent_memory.client.HttpTransport", return_value=sub_transport): - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme") - sub_transport.post.assert_called_once() - provider_transport.post.assert_not_called() - def test_provider_only_uses_provider_transport(self): - """PROVIDER routes calls to the provider transport.""" + """PROVIDER uses the provided transport.""" client, provider_transport = _make_client() provider_transport.post.return_value = { "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", } - client.add_memory("a", "u", "x", access_strategy=AccessStrategy.PROVIDER) + client.add_memory("a", "u", "x") provider_transport.post.assert_called_once() - def test_add_memory_subscriber_only_without_tenant_raises(self): - """add_memory with 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() - # ── Memory CRUD operations ──────────────────────────────────────────────────── @@ -254,7 +159,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" @@ -270,7 +175,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) + client.add_memory("a", "u", "x", metadata={"key": "val"}) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -282,7 +187,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) + client.add_memory("a", "u", "x") payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -295,7 +200,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) + client.add_memory("a", "u", "x") call_path = mock_transport.post.call_args[0][0] assert call_path == MEMORIES @@ -307,7 +212,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) + memory = client.get_memory("mem-1") assert memory.id == "mem-1" call_path = mock_transport.get.call_args[0][0] @@ -317,7 +222,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"] @@ -327,7 +232,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 @@ -336,7 +241,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"} @@ -346,7 +251,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] @@ -367,7 +272,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) @@ -377,7 +282,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"] @@ -388,7 +293,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" @@ -398,7 +303,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" @@ -408,7 +313,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 @@ -417,7 +322,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" @@ -427,7 +332,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 @@ -439,7 +344,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"] @@ -452,7 +356,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"] @@ -468,7 +371,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"] @@ -486,7 +388,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"] @@ -500,7 +401,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) + 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'" @@ -513,7 +414,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) + total = client.count_memories(agent_id="a", invoker_id="u") assert total == 42 @@ -522,7 +423,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" @@ -533,7 +434,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"] @@ -544,7 +445,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 @@ -564,7 +465,7 @@ def test_returns_results_in_api_order(self): ] } - 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) @@ -576,7 +477,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 @@ -592,7 +493,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 @@ -601,7 +502,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 @@ -628,7 +529,6 @@ 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, ) assert isinstance(message, Message) @@ -649,7 +549,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) + client.add_message("a", "u", "g", MessageRole.USER, "hi") call_path = mock_transport.post.call_args[0][0] assert call_path == MESSAGES @@ -663,7 +563,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) + 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"} @@ -676,7 +576,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) + client.add_message("a", "u", "g", MessageRole.USER, "hi") payload = mock_transport.post.call_args[1]["json"] assert "metadata" not in payload @@ -689,7 +589,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) + message = client.get_message("msg-1") assert message.id == "msg-1" call_path = mock_transport.get.call_args[0][0] @@ -699,7 +599,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] @@ -723,7 +623,7 @@ def test_returns_list_of_messages(self): ], } - 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) @@ -736,7 +636,6 @@ 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, ) params = mock_transport.get.call_args[1]["params"] @@ -751,7 +650,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" @@ -761,7 +660,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" @@ -771,7 +670,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 @@ -780,7 +679,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" @@ -790,7 +689,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 @@ -802,7 +701,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"] @@ -815,7 +713,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"] @@ -831,7 +728,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"] @@ -851,7 +747,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"] @@ -867,7 +762,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'" @@ -888,7 +783,7 @@ def test_get_retention_config(self): "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 @@ -902,7 +797,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] @@ -915,7 +810,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 @@ -956,55 +851,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) + 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: @@ -1013,57 +908,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) + 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 @@ -1074,49 +969,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) + 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: @@ -1125,31 +1020,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) + 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() @@ -1165,7 +1060,6 @@ 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, ) def test_list_memories_raises_for_empty_contains(self): @@ -1174,7 +1068,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): @@ -1183,7 +1076,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): @@ -1192,5 +1084,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, ) diff --git a/uv.lock b/uv.lock index 92e88dcb..18fcbff8 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -597,8 +597,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -3685,7 +3685,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.36.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 56029b6493daf88543e8b55d015871ae15a3c7a3 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Thu, 16 Jul 2026 15:28:32 -0300 Subject: [PATCH 9/9] refactor(agent-memory): remove unused AgentMemoryConfigError import --- src/sap_cloud_sdk/agent_memory/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index d715ffdd..9e28ac54 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -35,7 +35,6 @@ extract_value_and_count, ) from sap_cloud_sdk.agent_memory.exceptions import ( - AgentMemoryConfigError, AgentMemoryValidationError, ) from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics