diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 133247f4..467f0666 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -75,6 +75,9 @@ For Agent Memory integration tests, configure the following variables in `.env_i # Agent Memory Configuration CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL=https://your-agent-memory-api-url CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret"}' + +## For the multi-tenant scenarios, set the subscriber tenant subdomain. When not set, those scenarios are automatically skipped +CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT=your-subscriber-tenant-subdomain-here ``` ### AuditLog Integration Tests diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 6177df68..a67b6433 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -21,7 +21,6 @@ from sap_cloud_sdk.agent_memory.client import AgentMemoryClient from sap_cloud_sdk.agent_memory.config import ( AgentMemoryConfig, - _load_config_for_instance, _load_config_from_env, ) from sap_cloud_sdk.agent_memory.exceptions import ( @@ -52,12 +51,11 @@ def create_client( The binding loaded depends on ``access_strategy`` and ``tenant``: - - ``SUBSCRIBER`` with ``tenant="acme-corp"`` — loads the subscriber - binding from ``/etc/secrets/appfnd/hana-agent-memory/acme-corp/`` (or - ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). - - ``PROVIDER`` — loads the provider binding from + - ``SUBSCRIBER`` with ``tenant="acme-corp"`` — loads credentials from ``/etc/secrets/appfnd/hana-agent-memory/default/`` (or - ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars). + ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars) and derives the + subscriber token URL using the ``identityzone`` field. + - ``PROVIDER`` — same binding, uses provider token directly. - Explicit ``config`` — uses the provided configuration directly. Args: @@ -79,8 +77,6 @@ def create_client( try: if config is not None: resolved_config = config - elif access_strategy is AccessStrategy.SUBSCRIBER and tenant: - resolved_config = _load_config_for_instance(tenant) else: resolved_config = _load_config_from_env() diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index 9e28ac54..c0d4e6e3 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -77,13 +77,14 @@ class AgentMemoryClient: Do not instantiate directly — use :func:`sap_cloud_sdk.agent_memory.create_client`. Args: - transport: HTTP transport loaded from the binding for the configured - access strategy and tenant (resolved once at construction time by + transport: HTTP transport loaded from the default service binding + (resolved once at construction time by :func:`sap_cloud_sdk.agent_memory.create_client`). access_strategy: Tenant access strategy for all operations. Defaults to ``SUBSCRIBER``. tenant: Subscriber tenant subdomain. Required when - ``access_strategy=SUBSCRIBER``. + ``access_strategy=SUBSCRIBER``. The subscriber token URL is + derived from the provider binding's ``identityzone`` field. """ def __init__( @@ -103,6 +104,7 @@ def __init__( "Only use this strategy for provider-owned operations." ) self._transport = transport + self._tenant = tenant if access_strategy is AccessStrategy.SUBSCRIBER else None def close(self) -> None: """Close the underlying HTTP session and release resources.""" @@ -148,7 +150,9 @@ 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=self._tenant + ) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MEMORY) @@ -167,7 +171,9 @@ def get_memory(self, memory_id: str) -> Memory: AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - data = self._transport.get(f"{MEMORIES}({memory_id})") + data = self._transport.get( + f"{MEMORIES}({memory_id})", tenant_subdomain=self._tenant + ) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_MEMORY) @@ -201,7 +207,9 @@ def update_memory( 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=self._tenant + ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MEMORY) def delete_memory(self, memory_id: str) -> None: @@ -216,7 +224,9 @@ def delete_memory(self, memory_id: str) -> None: AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - self._transport.delete(f"{MEMORIES}({memory_id})") + self._transport.delete( + f"{MEMORIES}({memory_id})", tenant_subdomain=self._tenant + ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MEMORIES) def list_memories( @@ -264,7 +274,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=self._tenant + ) items, _ = extract_value_and_count(response) return [Memory.from_dict(item) for item in items] @@ -289,7 +301,9 @@ def count_memories( top=0, count=True, ) - response = self._transport.get(MEMORIES, params=params) + response = self._transport.get( + MEMORIES, params=params, tenant_subdomain=self._tenant + ) _, total = extract_value_and_count(response) return total or 0 @@ -336,7 +350,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=self._tenant + ) items = response.get("value", []) return [SearchResult.from_dict(item) for item in items] @@ -388,7 +404,9 @@ 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=self._tenant + ) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MESSAGE) @@ -407,7 +425,9 @@ def get_message(self, message_id: str) -> Message: AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - data = self._transport.get(f"{MESSAGES}({message_id})") + data = self._transport.get( + f"{MESSAGES}({message_id})", tenant_subdomain=self._tenant + ) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MESSAGE) @@ -423,7 +443,9 @@ def delete_message(self, message_id: str) -> None: AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - self._transport.delete(f"{MESSAGES}({message_id})") + self._transport.delete( + f"{MESSAGES}({message_id})", tenant_subdomain=self._tenant + ) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MESSAGES) def list_messages( @@ -477,7 +499,9 @@ 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=self._tenant + ) items, _ = extract_value_and_count(response) return [Message.from_dict(item) for item in items] @@ -493,9 +517,10 @@ def get_retention_config(self) -> RetentionConfig: The current :class:`RetentionConfig`. Raises: + AgentMemoryValidationError: If tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ - data = self._transport.get(RETENTION_CONFIG) + data = self._transport.get(RETENTION_CONFIG, tenant_subdomain=self._tenant) return RetentionConfig.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_RETENTION_CONFIG) @@ -541,4 +566,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=self._tenant + ) diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index 37569002..a5621b44 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -134,29 +134,6 @@ def _load_config_from_env() -> AgentMemoryConfig: 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/default/`` 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` - Returns: - A validated ``AgentMemoryConfig``. - - Raises: - AgentMemoryConfigError: If configuration cannot be loaded or is incomplete. - """ - return _load_config_for_instance("default") - - -def _load_config_for_instance(instance: str) -> AgentMemoryConfig: - """Load Agent Memory configuration for a named binding instance. - - Uses the secret resolver with fallback order: - 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/{instance}/`` - 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_{INSTANCE}_*`` - - This is used to load tenant-specific bindings when the runtime provisions - a dedicated service instance per subscriber tenant. - - Args: - instance: The binding instance name — ``"default"`` for the provider, - or a tenant subdomain for a subscriber (e.g. ``"acme-corp"``). - Returns: A validated ``AgentMemoryConfig``. @@ -173,7 +150,7 @@ def _load_config_for_instance(instance: str) -> AgentMemoryConfig: base_volume_mount="/etc/secrets/appfnd", base_var_name="CLOUD_SDK_CFG", module="hana-agent-memory", - instance=instance, + instance="default", target=binding, ) binding.validate() @@ -182,5 +159,5 @@ def _load_config_for_instance(instance: str) -> AgentMemoryConfig: raise except Exception as exc: raise AgentMemoryConfigError( - f"Failed to load Agent Memory configuration for instance '{instance}': {exc}" + f"Failed to load Agent Memory configuration: {exc}" ) from exc diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 3f611771..10e920d8 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -190,14 +190,11 @@ from sap_cloud_sdk.agent_memory import AccessStrategy ### Configuring at client level -Pass `access_strategy` and `tenant` to `create_client()` to set defaults for the entire -client instance. Every method call then inherits them, so you do not need to repeat them -on each operation. +Pass `access_strategy` and `tenant` to `create_client()` to set defaults for the entire client instance. Every method call then inherits them, so you do not need to repeat them on each operation. ```python from sap_cloud_sdk.agent_memory import create_client, AccessStrategy -# Tenant set once — all calls below use it automatically client = create_client( access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", @@ -221,7 +218,7 @@ memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") ### PROVIDER -Configure a provider-only client. No tenant is needed; all calls use the provider binding. +Configure a provider-only client. No tenant is needed and all calls use the provider binding. ```python client = create_client(access_strategy=AccessStrategy.PROVIDER) diff --git a/tests/agent_memory/integration/conftest.py b/tests/agent_memory/integration/conftest.py index 5bc95b69..77e2760a 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -2,21 +2,12 @@ Set the following environment variables before running integration tests: -Provider (default) binding: + CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_URL Base URL of the Agent Memory service + CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_AUTH_URL OAuth2 authorization server base URL + CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTID OAuth2 client ID + CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTSECRET OAuth2 client secret - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA - -Subscriber binding (one set per tenant, keyed by subdomain in upper-snake-case): - - CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL - CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA - - e.g. for tenant "acme-corp": - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA - -Subscriber tenant name: +Multitenancy: CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT Subscriber tenant subdomain Required for SUBSCRIBER tests. When absent those tests are skipped. @@ -54,16 +45,7 @@ def agent_memory_client() -> AgentMemoryClient: @pytest.fixture(scope="session") def subscriber_tenant() -> str: - """Return the subscriber tenant subdomain, or skip if not configured. - - On this branch, a separate binding must exist for the tenant subdomain: - /etc/secrets/appfnd/hana-agent-memory// - or environment variables: - CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL - CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA - """ - from sap_cloud_sdk.agent_memory.config import _load_config_for_instance - + """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) @@ -74,13 +56,4 @@ def subscriber_tenant() -> str: "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT not set — " "skipping subscriber tenant tests" ) - - try: - _load_config_for_instance(tenant) - except AgentMemoryConfigError: - pytest.skip( - f"Subscriber binding for tenant '{tenant}' not configured — " - f"skipping subscriber tenant tests" - ) - return tenant diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index 959ed315..cd6590cb 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -61,16 +61,17 @@ def test_uses_provided_config(self): assert isinstance(client, AgentMemoryClient) assert client._transport is not None - def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): - """Factory with SUBSCRIBER loads the tenant binding.""" + def test_subscriber_strategy_loads_default_binding(self, monkeypatch): + """Factory with SUBSCRIBER loads the default binding (native implementation).""" import json monkeypatch.setenv( - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", - "http://acme.memory.example.com", + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", + "http://memory.example.com", ) monkeypatch.setenv( - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", - json.dumps({"url": "http://acme.auth.example.com", "clientid": "c", "clientsecret": "s"}), + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", + json.dumps({"url": "http://auth.example.com", "clientid": "c", "clientsecret": "s", + "identityzone": "provider-zone"}), ) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) @@ -79,7 +80,7 @@ def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): tenant="acme-corp", ) assert isinstance(client, AgentMemoryClient) - assert client._transport is not None + assert client._tenant == "acme-corp" def test_provider_strategy_loads_default_binding(self, monkeypatch): """Factory with PROVIDER loads the default binding.""" @@ -143,11 +144,72 @@ def test_provider_only_uses_provider_transport(self): provider_transport.post.assert_called_once() +# ── Access strategy ─────────────────────────────────────────────────────────── + + +class TestAccessStrategy: + # ── Init-time validation ────────────────────────────────────────────────── + + def test_subscriber_without_tenant_raises_at_init(self): + """SUBSCRIBER without tenant raises AgentMemoryValidationError at construction.""" + transport = MagicMock(spec=HttpTransport) + with pytest.raises(AgentMemoryValidationError, match="tenant"): + AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) + + def test_subscriber_with_tenant_constructs_successfully(self): + """SUBSCRIBER with tenant constructs without error and stores tenant.""" + client, _ = _make_subscriber_client("acme") + assert client._tenant == "acme" + + def test_provider_constructs_without_tenant(self): + """PROVIDER constructs without tenant and stores None.""" + client, _ = _make_client() + assert client._tenant is None + + # ── Transport routing ───────────────────────────────────────────────────── + + def test_subscriber_passes_tenant_to_transport(self): + """SUBSCRIBER client passes tenant_subdomain to transport on every call.""" + client, transport = _make_subscriber_client("acme") + transport.post.return_value = { + "id": "m1", + "agentID": "a", + "invokerID": "u", + "content": "x", + } + + client.add_memory("a", "u", "x") + + assert transport.post.call_args[1]["tenant_subdomain"] == "acme" + + def test_provider_passes_none_tenant_to_transport(self): + """PROVIDER client passes tenant_subdomain=None to transport.""" + client, transport = _make_client() + transport.post.return_value = { + "id": "m1", + "agentID": "a", + "invokerID": "u", + "content": "x", + } + + client.add_memory("a", "u", "x") + + assert transport.post.call_args[1]["tenant_subdomain"] is None + + def test_list_memories_passes_tenant_to_transport(self): + """list_memories passes tenant_subdomain from client config.""" + client, transport = _make_subscriber_client("sub") + transport.get.return_value = {"value": []} + + client.list_memories(agent_id="a") + + assert transport.get.call_args[1]["tenant_subdomain"] == "sub" + + # ── Memory CRUD operations ──────────────────────────────────────────────────── class TestMemoryCRUD: - def test_add_memory_posts_correct_payload(self): """add_memory sends required and optional fields in the POST body.""" client, mock_transport = _make_client() @@ -172,7 +234,10 @@ def test_add_memory_with_metadata(self): """Optional metadata is included in the POST body when provided.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "x", } client.add_memory("a", "u", "x", metadata={"key": "val"}) @@ -184,7 +249,10 @@ def test_add_memory_excludes_none_optionals(self): """None-valued optional fields are omitted from the POST body.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "x", } client.add_memory("a", "u", "x") @@ -197,7 +265,10 @@ def test_add_memory_posts_to_memories_endpoint(self): """add_memory sends the POST to the MEMORIES endpoint.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "x", } client.add_memory("a", "u", "x") @@ -209,7 +280,10 @@ def test_get_memory_calls_get_with_memory_id(self): """get_memory constructs the correct path with the memory ID.""" client, mock_transport = _make_client() mock_transport.get.return_value = { - "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "hello", + "id": "mem-1", + "agentID": "a", + "invokerID": "u", + "content": "hello", } memory = client.get_memory("mem-1") @@ -262,7 +336,6 @@ def test_delete_memory_calls_delete(self): class TestListMemories: - def test_returns_list_of_memories(self): """list_memories returns a list of Memory objects.""" client, mock_transport = _make_client() @@ -408,7 +481,6 @@ def test_filter_none_does_not_change_behaviour(self): class TestCountMemories: - def test_returns_count_from_response(self): """count_memories returns the @odata.count value.""" client, mock_transport = _make_client() @@ -454,14 +526,25 @@ def test_returns_zero_when_count_missing(self): class TestSearchMemories: - def test_returns_results_in_api_order(self): """search_memories returns results in the order returned by the API.""" client, mock_transport = _make_client() mock_transport.post.return_value = { "value": [ - {"id": "m1", "agentID": "a", "invokerID": "u", "content": "first", "similarity": 0.5}, - {"id": "m2", "agentID": "a", "invokerID": "u", "content": "second", "similarity": 0.9}, + { + "id": "m1", + "agentID": "a", + "invokerID": "u", + "content": "first", + "similarity": 0.5, + }, + { + "id": "m2", + "agentID": "a", + "invokerID": "u", + "content": "second", + "similarity": 0.9, + }, ] } @@ -514,7 +597,6 @@ def test_uses_default_threshold_and_limit(self): class TestMessageCRUD: - def test_add_message_posts_correct_payload(self): """add_message sends required fields in the POST body.""" client, mock_transport = _make_client() @@ -528,7 +610,11 @@ def test_add_message_posts_correct_payload(self): } message = client.add_message( - "agent-a", "user-b", "conv-1", MessageRole.USER, "Hello!", + "agent-a", + "user-b", + "conv-1", + MessageRole.USER, + "Hello!", ) assert isinstance(message, Message) @@ -545,8 +631,12 @@ def test_add_message_posts_to_messages_endpoint(self): """add_message sends the POST to the MESSAGES endpoint.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", } client.add_message("a", "u", "g", MessageRole.USER, "hi") @@ -558,12 +648,18 @@ def test_add_message_with_metadata(self): """Optional metadata is included when provided.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", "metadata": {"key": "val"}, } - client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}) + client.add_message( + "a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"} + ) payload = mock_transport.post.call_args[1]["json"] assert payload["metadata"] == {"key": "val"} @@ -572,8 +668,12 @@ def test_add_message_excludes_none_metadata(self): """None-valued metadata is omitted from the POST body.""" client, mock_transport = _make_client() mock_transport.post.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", } client.add_message("a", "u", "g", MessageRole.USER, "hi") @@ -585,8 +685,12 @@ def test_get_message_calls_get_with_message_id(self): """get_message constructs the correct path with the message ID.""" client, mock_transport = _make_client() mock_transport.get.return_value = { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", } message = client.get_message("msg-1") @@ -610,15 +714,18 @@ def test_delete_message_calls_delete(self): class TestListMessages: - def test_returns_list_of_messages(self): """list_messages returns a list of Message objects.""" client, mock_transport = _make_client() mock_transport.get.return_value = { "value": [ { - "id": "msg-1", "agentID": "a", "invokerID": "u", - "messageGroup": "g", "role": "USER", "content": "hi", + "id": "msg-1", + "agentID": "a", + "invokerID": "u", + "messageGroup": "g", + "role": "USER", + "content": "hi", }, ], } @@ -634,8 +741,10 @@ def test_passes_convenience_filters(self): mock_transport.get.return_value = {"value": []} client.list_messages( - agent_id="a", invoker_id="u", - message_group="conv-1", role="USER", + agent_id="a", + invoker_id="u", + message_group="conv-1", + role="USER", ) params = mock_transport.get.call_args[1]["params"] @@ -772,12 +881,13 @@ def test_filter_none_does_not_change_behaviour(self): class TestRetentionConfig: - def test_get_retention_config(self): """get_retention_config sends GET to the retentionConfig endpoint.""" client, mock_transport = _make_client() mock_transport.get.return_value = { - "id": 1, "messageDays": 30, "memoryDays": 90, + "id": 1, + "messageDays": 30, + "memoryDays": 90, "usageLogDays": 180, "createTimestamp": "2025-01-01T00:00:00Z", "updateTimestamp": "2025-01-02T00:00:00Z", @@ -822,7 +932,6 @@ def test_update_retention_config_excludes_none_fields(self): class TestContextManager: - def test_close_delegates_to_transport(self): """close() delegates to the transport's close method.""" client, mock_transport = _make_client() @@ -846,7 +955,6 @@ def test_context_manager_closes_on_exit(self): class TestMemoryValidation: - def test_add_memory_raises_for_empty_agent_id(self): """add_memory raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() @@ -903,7 +1011,6 @@ def test_list_memories_raises_for_negative_offset(self): class TestSearchMemoriesValidation: - def test_raises_for_empty_agent_id(self): """search_memories raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() @@ -964,7 +1071,6 @@ def test_boundary_values_are_accepted(self): class TestMessageValidation: - def test_add_message_raises_for_empty_agent_id(self): """add_message raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() @@ -1015,7 +1121,6 @@ def test_list_messages_raises_for_negative_offset(self): class TestRetentionConfigValidation: - def test_update_raises_when_no_fields_provided(self): """update_retention_config raises AgentMemoryValidationError when no fields are provided.""" client, _ = _make_client() @@ -1053,7 +1158,6 @@ def test_update_accepts_zero_values(self): class TestFilterDefinitionValidation: - def test_list_memories_raises_for_unsupported_target(self): """list_memories raises AgentMemoryValidationError for an unknown target.""" client, _ = _make_client() diff --git a/tests/agent_memory/unit/test_config.py b/tests/agent_memory/unit/test_config.py index ce297514..40e9b2ea 100644 --- a/tests/agent_memory/unit/test_config.py +++ b/tests/agent_memory/unit/test_config.py @@ -1,4 +1,4 @@ -"""Unit tests for AgentMemoryConfig, BindingData, _load_config_from_env, and _load_config_for_instance.""" +"""Unit tests for AgentMemoryConfig, BindingData, and _load_config_from_env.""" import json from unittest.mock import patch @@ -8,7 +8,6 @@ from sap_cloud_sdk.agent_memory.config import ( AgentMemoryConfig, BindingData, - _load_config_for_instance, _load_config_from_env, ) from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @@ -211,77 +210,3 @@ def test_raises_config_error_when_uaa_json_invalid(self, monkeypatch): with patch("os.stat", side_effect=FileNotFoundError("no mount")): with pytest.raises(AgentMemoryConfigError, match="Failed to parse uaa JSON"): _load_config_from_env() - - -# ── _load_config_for_instance ───────────────────────────────────────────────── - - -def _fill_binding_for_instance(instance_name: str): - """Return a side_effect that fills binding only when instance matches.""" - def _fill(**kwargs): - assert kwargs["instance"] == instance_name - kwargs["target"].application_url = f"https://{instance_name}.memory.example.com" - kwargs["target"].uaa = json.dumps({ - "url": f"https://{instance_name}.auth.example.com", - "clientid": f"{instance_name}-client", - "clientsecret": "secret", - }) - return _fill - - -class TestLoadConfigForInstance: - - def test_loads_named_instance_binding(self): - """Loads config from the specified instance name (not 'default').""" - with patch(_RESOLVER, side_effect=_fill_binding_for_instance("acme-corp")): - config = _load_config_for_instance("acme-corp") - - assert config.base_url == "https://acme-corp.memory.example.com" - assert config.token_url == "https://acme-corp.auth.example.com/oauth/token" - assert config.client_id == "acme-corp-client" - - def test_calls_resolver_with_correct_instance(self): - """Resolver receives the exact instance name passed (not 'default').""" - with patch(_RESOLVER, side_effect=_fill_binding_for_instance("beta-tenant")) as mock_resolver: - _load_config_for_instance("beta-tenant") - - _, kwargs = mock_resolver.call_args - assert kwargs["module"] == "hana-agent-memory" - assert kwargs["instance"] == "beta-tenant" - - def test_default_instance_is_equivalent_to_load_config_from_env(self): - """_load_config_for_instance('default') produces the same result as _load_config_from_env.""" - with patch(_RESOLVER, side_effect=_fill_binding_for_instance("default")): - config_instance = _load_config_for_instance("default") - with patch(_RESOLVER, side_effect=_fill_binding_for_instance("default")): - config_env = _load_config_from_env() - - assert config_instance.base_url == config_env.base_url - assert config_instance.token_url == config_env.token_url - - def test_raises_with_instance_name_in_message_when_binding_missing(self): - """Error message includes the instance name when the binding cannot be loaded.""" - with patch(_RESOLVER, side_effect=RuntimeError("secrets not found")): - with pytest.raises(AgentMemoryConfigError, match="acme-corp"): - _load_config_for_instance("acme-corp") - - def test_loads_from_env_vars_for_named_instance(self, monkeypatch): - """Subscriber binding loaded from env vars keyed by tenant name.""" - monkeypatch.setenv( - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", - "https://acme-corp.memory.example.com", - ) - monkeypatch.setenv( - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", - json.dumps({ - "url": "https://acme-corp.auth.example.com", - "clientid": "acme-client", - "clientsecret": "secret", - }), - ) - - with patch("os.stat", side_effect=FileNotFoundError("no mount")): - config = _load_config_for_instance("acme-corp") - - assert config.base_url == "https://acme-corp.memory.example.com" - assert config.client_id == "acme-client" diff --git a/uv.lock b/uv.lock index c4d7eca3..da02ed41 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -597,8 +597,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -3685,7 +3685,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.36.0" +version = "0.37.0" source = { editable = "." } dependencies = [ { name = "grpcio" },