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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/INTEGRATION_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 4 additions & 8 deletions src/sap_cloud_sdk/agent_memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down
59 changes: 43 additions & 16 deletions src/sap_cloud_sdk/agent_memory/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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]

Expand All @@ -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

Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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]

Expand All @@ -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)
Expand Down Expand Up @@ -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
)
27 changes: 2 additions & 25 deletions src/sap_cloud_sdk/agent_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand All @@ -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()
Expand All @@ -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
7 changes: 2 additions & 5 deletions src/sap_cloud_sdk/agent_memory/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand Down
39 changes: 6 additions & 33 deletions tests/agent_memory/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<TENANT>_APPLICATION_URL
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_<TENANT>_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.
Expand Down Expand Up @@ -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/<tenant>/
or environment variables:
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_<TENANT>_APPLICATION_URL
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_<TENANT>_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)
Expand All @@ -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
Loading
Loading