Skip to content
Open
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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ dev = [
markers = [
"integration: marks tests as integration tests (requires Docker)",
"aicore: marks tests that require AI Core credentials (AICORE_* env vars)",
"no_autouse_credentials: opts out of the default no-credentials fixture in checkpointer tests",
]

[tool.coverage.run]
Expand Down Expand Up @@ -148,3 +149,4 @@ exclude = [
"src/sap_cloud_sdk/core/auditlog_ng/gen",
"src/buf",
]

58 changes: 45 additions & 13 deletions src/sap_cloud_sdk/agent_memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
AgentMemoryError,
AgentMemoryHttpError,
AgentMemoryNotFoundError,
AgentMemoryThreadConflictError,
AgentMemoryThreadExpiredError,
AgentMemoryThreadNotFoundError,
AgentMemoryValidationError,
)
from sap_cloud_sdk.agent_memory._models import (
Expand All @@ -38,19 +41,34 @@
SearchResult,
)
from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition
from sap_cloud_sdk.core.protocol.http import HttpClient, XsuaaAuthProvider
from sap_cloud_sdk.agent_memory.thread_registry import (
BaseThreadRegistry,
InMemoryThreadRegistry,
SAPAgentMemoryThreadRegistry,
ThreadPage,
ThreadRegistryEntry,
create_thread_registry,
)
from sap_cloud_sdk.core.protocol.http import (
HttpClient,
IasAuthProvider,
XsuaaAuthProvider,
)


def _build_agent_memory_http(
instance: str, config: Optional[AgentMemoryConfig]
) -> HttpClient:
if config is not None:
auth = XsuaaAuthProvider(lambda: config) if config.token_url else None
return HttpClient(config.base_url, auth, timeout=config.timeout)
factory = _make_config_factory(instance)
cfg = factory()
auth = XsuaaAuthProvider(factory) if cfg.token_url else None
return HttpClient(cfg.base_url, auth, timeout=cfg.timeout)
) -> tuple[HttpClient, bool]:
"""Build an :class:`HttpClient` for the given config, returning (client, is_ias)."""
# Explicit config is wrapped as a static factory; otherwise the binding is read
# via a ConfigFactory that re-reads on each call to pick up secret rotation.
config_factory = (
(lambda: config) if config is not None else _make_config_factory(instance)
)
cfg = config_factory()
is_ias = cfg.auth_mode == "ias"
auth = IasAuthProvider() if is_ias else XsuaaAuthProvider(config_factory)
return HttpClient(cfg.base_url, auth, timeout=cfg.timeout), is_ias


def create_client(
Expand All @@ -68,6 +86,10 @@ def create_client(
``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.
- **IAS binding** (URL-only, no ``token_url``) — detected from the config:
``access_strategy`` and ``tenant`` are ignored, and the per-request Bearer
token is supplied via RuntimeContext (wired by ``bootstrap(app)`` on the
server, or set manually for scripts).
- Explicit ``config`` — uses the provided configuration directly.

Args:
Expand All @@ -87,10 +109,11 @@ def create_client(
``tenant`` is not provided.
"""
try:
if config is not None:
http = _build_agent_memory_http("default", config)
else:
http = _build_agent_memory_http("default", None)
http, is_ias = _build_agent_memory_http("default", config)
if is_ias:
# IAS mode: calls are routed by the per-request token, not by an
# XSUAA access strategy — tenant/access_strategy do not apply.
return AgentMemoryClient(http, _ias_mode=True)
return AgentMemoryClient(
http,
access_strategy=access_strategy,
Expand All @@ -112,12 +135,21 @@ def create_client(
"AgentMemoryConfigError",
"AgentMemoryHttpError",
"AgentMemoryNotFoundError",
"AgentMemoryThreadConflictError",
"AgentMemoryThreadExpiredError",
"AgentMemoryThreadNotFoundError",
"AgentMemoryValidationError",
"FilterDefinition",
"Memory",
"Message",
"MessageRole",
"RetentionConfig",
"SearchResult",
"BaseThreadRegistry",
"InMemoryThreadRegistry",
"SAPAgentMemoryThreadRegistry",
"ThreadPage",
"ThreadRegistryEntry",
"create_client",
"create_thread_registry",
]
17 changes: 16 additions & 1 deletion src/sap_cloud_sdk/agent_memory/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@


class AccessStrategy(str, Enum):
"""Access strategy for tenant-scoped Agent Memory operations."""
"""Access strategy for XSUAA tenant-scoped Agent Memory operations.

IAS bindings need no strategy: they are detected from a URL-only binding
(no ``token_url``) and the per-request token is supplied via the ContextVar.
"""

SUBSCRIBER = "SUBSCRIBER"
PROVIDER = "PROVIDER"
Expand Down Expand Up @@ -224,6 +228,11 @@ class RetentionConfig:
message_days: How long to keep messages (days). ``None`` disables cleanup.
memory_days: How long to keep memories without access (days). ``None`` disables cleanup.
usage_log_days: How long to keep access and search logs (days). ``None`` disables cleanup.
thread_days: Default TTL (days) applied only to registered threads (those created
via the Thread Registry API). Age-based cutoff for thread cleanup; unregistered
threads are unaffected. ``None`` disables age-based cleanup.
max_checkpoints_per_thread: Keep at most N checkpoints per ``(thread, scope)``,
deleting oldest first. ``None`` disables count-based cleanup.
create_timestamp: ISO-8601 creation timestamp (read-only).
update_timestamp: ISO-8601 last-update timestamp (read-only).
"""
Expand All @@ -232,6 +241,8 @@ class RetentionConfig:
message_days: Optional[int] = None
memory_days: Optional[int] = None
usage_log_days: Optional[int] = None
thread_days: Optional[int] = None
max_checkpoints_per_thread: Optional[int] = None
create_timestamp: Optional[str] = None
update_timestamp: Optional[str] = None

Expand All @@ -243,6 +254,8 @@ def from_dict(cls, data: dict[str, Any]) -> RetentionConfig:
message_days=data.get("messageDays"),
memory_days=data.get("memoryDays"),
usage_log_days=data.get("usageLogDays"),
thread_days=data.get("threadDays"),
max_checkpoints_per_thread=data.get("maxCheckpointsPerThread"),
create_timestamp=data.get("createTimestamp"),
update_timestamp=data.get("updateTimestamp"),
)
Expand All @@ -254,6 +267,8 @@ def to_dict(self) -> dict[str, Any]:
"messageDays": self.message_days,
"memoryDays": self.memory_days,
"usageLogDays": self.usage_log_days,
"threadDays": self.thread_days,
"maxCheckpointsPerThread": self.max_checkpoints_per_thread,
"createTimestamp": self.create_timestamp,
"updateTimestamp": self.update_timestamp,
}
62 changes: 58 additions & 4 deletions src/sap_cloud_sdk/agent_memory/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
_MESSAGE_DAYS_KEY = "messageDays"
_MEMORY_DAYS_KEY = "memoryDays"
_USAGE_LOG_DAYS_KEY = "usageLogDays"
_THREAD_DAYS_KEY = "threadDays"


def _require_non_empty(**fields: str) -> None:
Expand All @@ -71,6 +72,25 @@ def _require_non_empty(**fields: str) -> None:
)


def _require_ias_token() -> None:
"""Raise AgentMemoryValidationError if no IAS token is in the RuntimeContext.

In IAS mode the per-request bearer token must be placed in the
RuntimeContext by the IAS context provider (wired via ``bootstrap(app)``).
Checking here — before the request is dispatched — surfaces a clear
``AgentMemoryValidationError``; the HTTP layer would otherwise send an
unauthenticated request and the service would reject it with a 401.
"""
from sap_cloud_sdk.core.runtime_context import get_context
from sap_cloud_sdk.core.runtime_context.providers._ias import IAS_TOKEN

if not get_context().get(IAS_TOKEN):
raise AgentMemoryValidationError(
"IAS token not found in RuntimeContext. "
"Ensure bootstrap(app) is configured."
)


def _validate_filter_clauses(
clauses: list[FilterDefinition], allowed_targets: set[str]
) -> None:
Expand All @@ -96,7 +116,7 @@ class AgentMemoryClient:
Do not instantiate directly — use :func:`sap_cloud_sdk.agent_memory.create_client`.

Args:
transport: HTTP transport loaded from the default service binding
http: HTTP client 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.
Expand All @@ -112,7 +132,15 @@ def __init__(
*,
access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER,
tenant: Optional[str] = None,
_ias_mode: bool = False,
) -> None:
if _ias_mode:
# IAS mode: calls are routed by the per-request token, so the XSUAA
# access_strategy/tenant parameters do not apply and are not validated.
self._http = http
self._ias_mode = True
self._tenant = None
return
if access_strategy is AccessStrategy.SUBSCRIBER and not tenant:
raise AgentMemoryValidationError(
"tenant is required when access_strategy=SUBSCRIBER"
Expand All @@ -124,6 +152,7 @@ def __init__(
"Only use this strategy for provider-owned operations."
)
self._http = http
self._ias_mode = False
self._tenant = tenant if access_strategy is AccessStrategy.SUBSCRIBER else None

def close(self) -> None:
Expand All @@ -146,6 +175,8 @@ def _request(
**kwargs: Any,
) -> dict[str, Any]:
logger.debug("%s %s (tenant=%r)", method.value, path, tenant_subdomain)
if self._ias_mode:
_require_ias_token()
if params:
path = f"{path}?{urlencode(params, quote_via=quote)}"
try:
Expand All @@ -164,6 +195,8 @@ def _request(
raise AgentMemoryHttpError(
f"Request failed: {method.value} {path} — {exc}"
) from exc
except AgentMemoryValidationError:
raise
except Exception as exc:
raise AgentMemoryHttpError(str(exc)) from exc
if response.status_code == 204 or not response.content:
Expand Down Expand Up @@ -605,6 +638,8 @@ def update_retention_config(
message_days: Optional[int] = None,
memory_days: Optional[int] = None,
usage_log_days: Optional[int] = None,
thread_days: Optional[int] = None,
max_checkpoints_per_thread: Optional[int] = None,
) -> None:
"""Update the data retention configuration.

Expand All @@ -616,21 +651,36 @@ 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).
thread_days: Default TTL (days) applied only to registered threads
(those created via the Thread Registry API). Age-based cutoff for
thread cleanup; unregistered threads are unaffected. ``None`` leaves
the server setting unchanged. ``0`` disables age-based cleanup.
max_checkpoints_per_thread: Keep at most N checkpoints per
``(thread, scope)``, deleting oldest first. ``None`` leaves
the server setting unchanged. ``0`` disables count-based cleanup.

Raises:
AgentMemoryValidationError: If no fields are provided, any provided value is
negative, or tenant is missing for ``SUBSCRIBER``.
AgentMemoryHttpError: If the request fails.
"""
if message_days is None and memory_days is None and usage_log_days is None:
if (
message_days is None
and memory_days is None
and usage_log_days is None
and thread_days is None
and max_checkpoints_per_thread is None
):
raise AgentMemoryValidationError(
"At least one of 'message_days', 'memory_days', or "
"'usage_log_days' must be provided"
"At least one of 'message_days', 'memory_days', 'usage_log_days', "
"'thread_days', or 'max_checkpoints_per_thread' must be provided"
)
for name, value in (
("message_days", message_days),
("memory_days", memory_days),
("usage_log_days", usage_log_days),
("thread_days", thread_days),
("max_checkpoints_per_thread", max_checkpoints_per_thread),
):
if value is not None and value < 0:
raise AgentMemoryValidationError(f"'{name}' must be >= 0")
Expand All @@ -642,6 +692,10 @@ def update_retention_config(
payload[_MEMORY_DAYS_KEY] = memory_days
if usage_log_days is not None:
payload[_USAGE_LOG_DAYS_KEY] = usage_log_days
if thread_days is not None:
payload[_THREAD_DAYS_KEY] = thread_days
if max_checkpoints_per_thread is not None:
payload["maxCheckpointsPerThread"] = max_checkpoints_per_thread

self._request(
HttpMethod.PATCH,
Expand Down
Loading
Loading