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..9ec4daad 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,23 @@ 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) + return self._request( + "GET", path, params=params, tenant_subdomain=tenant_subdomain + ) - def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + 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 +102,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 +125,51 @@ 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 - self._oauth = self._fetch_token() - return self._oauth + return self._fetch_token(tenant_subdomain) - def _fetch_token(self) -> OAuth2Session: - """Acquire a new OAuth2 ``client_credentials`` token. + def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: + """Acquire a new OAuth2 ``client_credentials`` token for the given tenant. + + 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 +177,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 +198,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 +231,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..2248d644 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,40 @@ 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 +190,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 +199,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 +213,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 +259,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 +274,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 +291,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 +301,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 +312,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 +351,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 +363,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 +384,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 +392,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 +410,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 +425,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 +442,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 +452,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 +526,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 +543,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 +560,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 +572,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 +611,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 +624,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 +644,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 +652,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")