Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env_integration_tests.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here

CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here
CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}'

CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL=https://your-agent-memory-api-url-here
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL=https://your-auth-url-here
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID=your-client-id-here
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET=your-client-secret-here
15 changes: 15 additions & 0 deletions docs/INTEGRATION_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ Integration tests verify that the SDK modules work correctly with real external
## Prerequisites

### Required Tools

- **Python 3.11+**: Required for running the tests
- **uv**: Package manager for dependency management

### Install Dependencies

```bash
# Install all dependencies including test dependencies
uv sync --all-extras
Expand Down Expand Up @@ -62,6 +64,18 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI=https://your-destination-configuration-uri
CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here
```

### Agent Memory Integration Tests

For Agent Memory integration tests, configure the following variables in `.env_integration_tests`:

```bash
# Agent Memory Configuration
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_URL=https://your-agent-memory-api-url
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_AUTH_URL=https://your-auth-url
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTID=your-client-id
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTSECRET=your-client-secret
```

## Running Integration Tests

```bash
Expand All @@ -72,6 +86,7 @@ uv run pytest tests/ -m integration -v
uv run pytest tests/core/integration/auditlog -v
uv run pytest tests/objectstore/integration/ -v
uv run pytest tests/destination/integration/ -v
uv run pytest tests/agent_memory/integration/ -v
```

### BDD Scenarios
Expand Down
78 changes: 78 additions & 0 deletions src/sap_cloud_sdk/agent_memory/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""SAP Cloud SDK for Python — Agent Memory module.

The ``create_client()`` function auto-detects credentials from a mounted volume
or ``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables.

Usage::

from sap_cloud_sdk.agent_memory import create_client

client = create_client()
memories = client.list_memories(agent_id="my-agent", invoker_id="user-123")
"""

from typing import Optional

from sap_cloud_sdk.agent_memory._http_transport import HttpTransport
from sap_cloud_sdk.agent_memory.client import AgentMemoryClient
from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_config_from_env
from sap_cloud_sdk.agent_memory.exceptions import (
AgentMemoryConfigError,
AgentMemoryError,
AgentMemoryHttpError,
AgentMemoryNotFoundError,
AgentMemoryValidationError,
)
from sap_cloud_sdk.agent_memory._models import (
Memory,
Message,
MessageRole,
RetentionConfig,
SearchResult,
)
from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition


def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryClient:
"""Create an :class:`AgentMemoryClient` with automatic credential detection.

Args:
config: Optional explicit configuration. If ``None``, credentials are
loaded from the mounted volume at
``/etc/secrets/appfnd/hana-agent-memory/default/`` or from
``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables.

Returns:
A ready-to-use :class:`AgentMemoryClient`.

Raises:
AgentMemoryConfigError: If configuration is missing or invalid.
"""
try:
resolved_config = config if config is not None else _load_config_from_env()
transport = HttpTransport(resolved_config)
return AgentMemoryClient(transport)
except AgentMemoryConfigError:
raise
except Exception as exc:
raise AgentMemoryConfigError(
f"Failed to create Agent Memory client: {exc}"
) from exc


__all__ = [
"AgentMemoryClient",
"AgentMemoryConfig",
"AgentMemoryError",
"AgentMemoryConfigError",
"AgentMemoryHttpError",
"AgentMemoryNotFoundError",
"AgentMemoryValidationError",
"FilterDefinition",
"Memory",
"Message",
"MessageRole",
"RetentionConfig",
"SearchResult",
"create_client",
]
44 changes: 44 additions & 0 deletions src/sap_cloud_sdk/agent_memory/_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Agent Memory API endpoint path constants.

All endpoint paths are centralised here so that migrating to a new API version
requires changes in only this one file.

Current API version: v1
- Memories CRUD + search: /v1/memories
- Messages CRUD: /v1/messages
- Admin (retention): /v1/admin/retentionConfig
"""

from __future__ import annotations

# ── Base path ──────────────────────────────────────────────────────────────────

BASE_PATH = "/v1"

# ── Memory endpoints ──────────────────────────────────────────────────────────

MEMORIES = f"{BASE_PATH}/memories"
# POST MEMORIES → create memory
# GET MEMORIES → list memories (with OData $filter / $top / $skip)
# GET MEMORIES({id}) → get memory
# PATCH MEMORIES({id}) → update memory
# DELETE MEMORIES({id}) → delete memory

MEMORY_SEARCH = f"{MEMORIES}/search"
# POST MEMORY_SEARCH → semantic similarity search

# ── Message endpoints ─────────────────────────────────────────────────────────

MESSAGES = f"{BASE_PATH}/messages"
# POST MESSAGES → create message
# GET MESSAGES → list messages (with OData $filter / $top / $skip)
# GET MESSAGES({id}) → get message
# DELETE MESSAGES({id}) → delete message (not updatable)

# ── Admin endpoints ───────────────────────────────────────────────────────────

ADMIN_BASE_PATH = "/v1/admin"

RETENTION_CONFIG = f"{ADMIN_BASE_PATH}/retentionConfig"
# GET RETENTION_CONFIG → get singleton retention config
# PATCH RETENTION_CONFIG → update retention policy
221 changes: 221 additions & 0 deletions src/sap_cloud_sdk/agent_memory/_http_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""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.
"""

from __future__ import annotations

import logging
from datetime import datetime, timedelta
from typing import Any, Optional
from urllib.parse import quote, urlencode

import requests
from oauthlib.oauth2 import BackendApplicationClient
from requests.exceptions import RequestException, Timeout
from requests_oauthlib import OAuth2Session

from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig
from sap_cloud_sdk.agent_memory.exceptions import (
AgentMemoryHttpError,
AgentMemoryNotFoundError,
)

logger = logging.getLogger(__name__)

_TOKEN_EXPIRY_BUFFER_SECONDS = 60


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.

Args:
config: Service configuration.
"""

def __init__(self, config: AgentMemoryConfig) -> None:
self._config = config
self._oauth: Optional[OAuth2Session] = None
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
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]:
"""Perform a GET request.

Args:
path: API path (appended to ``base_url``).
params: Optional query parameters.

Returns:
Parsed JSON response body.

Raises:
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
return self._request("GET", path, params=params)

def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
"""Perform a POST request.

Args:
path: API path (appended to ``base_url``).
json: Optional request body dict (serialised to JSON).

Returns:
Parsed JSON response body. Returns an empty dict for 204 responses.

Raises:
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]:
"""Perform a PATCH request.

Args:
path: API path (appended to ``base_url``).
json: Optional request body dict (serialised to JSON).

Returns:
Parsed JSON response body. Returns an empty dict for 204 responses.

Raises:
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
return self._request("PATCH", path, json=json)

def delete(self, path: str) -> None:
"""Perform a DELETE request.

Args:
path: API path (appended to ``base_url``).

Raises:
AgentMemoryHttpError: On HTTP errors or network failures.
AgentMemoryNotFoundError: If the server returns 404.
"""
self._request("DELETE", path)

# ── Internal helpers ───────────────────────────────────────────────────────

def _get_session(self) -> requests.Session:
"""Return a session ready to make requests.

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.
"""
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

self._oauth = self._fetch_token()
return self._oauth

def _fetch_token(self) -> OAuth2Session:
"""Acquire a new OAuth2 ``client_credentials`` token.

Returns:
An ``OAuth2Session`` with a valid token attached.

Raises:
AgentMemoryHttpError: If the token endpoint returns an error or is unreachable.
"""
try:
client = BackendApplicationClient(client_id=self._config.client_id)
oauth = OAuth2Session(client=client)
token = oauth.fetch_token(
token_url=self._config.token_url,
client_id=self._config.client_id,
client_secret=self._config.client_secret,
timeout=self._config.timeout,
)
except Exception as exc:
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(
seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS
)

if self._oauth is not None:
self._oauth.close()

logger.debug(
"Obtained new Agent Memory OAuth2 token (expires in %ds)", expires_in
)
return oauth

def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
"""Execute an HTTP request using the appropriate session."""
logger.debug("%s %s", method, path)

url = f"{self._config.base_url}{path}"
if "params" in kwargs:
raw_params: dict[str, Any] = kwargs.pop("params")
if raw_params:
url = f"{url}?{urlencode(raw_params, quote_via=quote)}"

session = self._get_session()
headers = {"Content-Type": "application/json"}

try:
response = session.request(
method, url, headers=headers, timeout=self._config.timeout, **kwargs
)
except Timeout as exc:
raise AgentMemoryHttpError(f"Request timed out: {method} {path}") from exc
except RequestException as exc:
raise AgentMemoryHttpError(
f"Request failed: {method} {path} — {exc}"
) from exc

if response.status_code == 204 or not response.content:
return {}

if response.status_code == 404:
raise AgentMemoryNotFoundError(
f"Resource not found: {method} {path}",
status_code=404,
response_text=response.text,
)

if not response.ok:
raise AgentMemoryHttpError(
f"Agent Memory service request failed. "
f"Method: {method}, Path: {path}, "
f"Status: {response.status_code}, Response: {response.text}",
status_code=response.status_code,
response_text=response.text,
)

return response.json()
Loading
Loading