Skip to content

Commit aab40a4

Browse files
feat(agent-memory): add agent memory client module
1 parent 633f814 commit aab40a4

29 files changed

Lines changed: 5143 additions & 3 deletions

.env_integration_tests.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here
1616

1717
CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here
1818
CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}'
19+
20+
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL=https://your-agent-memory-api-url-here
21+
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL=https://your-auth-url-here
22+
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID=your-client-id-here
23+
CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET=your-client-secret-here

docs/INTEGRATION_TESTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ Integration tests verify that the SDK modules work correctly with real external
99
## Prerequisites
1010

1111
### Required Tools
12+
1213
- **Python 3.11+**: Required for running the tests
1314
- **uv**: Package manager for dependency management
1415

1516
### Install Dependencies
17+
1618
```bash
1719
# Install all dependencies including test dependencies
1820
uv sync --all-extras
@@ -62,6 +64,18 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI=https://your-destination-configuration-uri
6264
CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here
6365
```
6466

67+
### Agent Memory Integration Tests
68+
69+
For Agent Memory integration tests, configure the following variables in `.env_integration_tests`:
70+
71+
```bash
72+
# Agent Memory Configuration
73+
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_URL=https://your-agent-memory-api-url
74+
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_AUTH_URL=https://your-auth-url
75+
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTID=your-client-id
76+
CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTSECRET=your-client-secret
77+
```
78+
6579
## Running Integration Tests
6680

6781
```bash
@@ -72,6 +86,7 @@ uv run pytest tests/ -m integration -v
7286
uv run pytest tests/core/integration/auditlog -v
7387
uv run pytest tests/objectstore/integration/ -v
7488
uv run pytest tests/destination/integration/ -v
89+
uv run pytest tests/agent_memory/integration/ -v
7590
```
7691

7792
### BDD Scenarios
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""SAP Cloud SDK for Python — Agent Memory module.
2+
3+
The ``create_client()`` function auto-detects credentials from a mounted volume
4+
or ``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables.
5+
6+
Usage::
7+
8+
from sap_cloud_sdk.agent_memory import create_client
9+
10+
client = create_client()
11+
memories = client.list_memories(agent_id="my-agent", invoker_id="user-123")
12+
"""
13+
14+
from typing import Optional
15+
16+
from sap_cloud_sdk.agent_memory._http_transport import HttpTransport
17+
from sap_cloud_sdk.agent_memory.client import AgentMemoryClient
18+
from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_config_from_env
19+
from sap_cloud_sdk.agent_memory.exceptions import (
20+
AgentMemoryConfigError,
21+
AgentMemoryError,
22+
AgentMemoryHttpError,
23+
AgentMemoryNotFoundError,
24+
AgentMemoryValidationError,
25+
)
26+
from sap_cloud_sdk.agent_memory._models import (
27+
Memory,
28+
Message,
29+
MessageRole,
30+
RetentionConfig,
31+
SearchResult,
32+
)
33+
from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition
34+
35+
36+
def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryClient:
37+
"""Create an :class:`AgentMemoryClient` with automatic credential detection.
38+
39+
Args:
40+
config: Optional explicit configuration. If ``None``, credentials are
41+
loaded from the mounted volume at
42+
``/etc/secrets/appfnd/hana-agent-memory/default/`` or from
43+
``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables.
44+
45+
Returns:
46+
A ready-to-use :class:`AgentMemoryClient`.
47+
48+
Raises:
49+
AgentMemoryConfigError: If configuration is missing or invalid.
50+
"""
51+
try:
52+
resolved_config = config if config is not None else _load_config_from_env()
53+
transport = HttpTransport(resolved_config)
54+
return AgentMemoryClient(transport)
55+
except AgentMemoryConfigError:
56+
raise
57+
except Exception as exc:
58+
raise AgentMemoryConfigError(
59+
f"Failed to create Agent Memory client: {exc}"
60+
) from exc
61+
62+
63+
__all__ = [
64+
"AgentMemoryClient",
65+
"AgentMemoryConfig",
66+
"AgentMemoryError",
67+
"AgentMemoryConfigError",
68+
"AgentMemoryHttpError",
69+
"AgentMemoryNotFoundError",
70+
"AgentMemoryValidationError",
71+
"FilterDefinition",
72+
"Memory",
73+
"Message",
74+
"MessageRole",
75+
"RetentionConfig",
76+
"SearchResult",
77+
"create_client",
78+
]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Agent Memory API endpoint path constants.
2+
3+
All endpoint paths are centralised here so that migrating to a new API version
4+
requires changes in only this one file.
5+
6+
Current API version: v1
7+
- Memories CRUD + search: /v1/memories
8+
- Messages CRUD: /v1/messages
9+
- Admin (retention): /v1/admin/retentionConfig
10+
"""
11+
12+
from __future__ import annotations
13+
14+
# ── Base path ──────────────────────────────────────────────────────────────────
15+
16+
BASE_PATH = "/v1"
17+
18+
# ── Memory endpoints ──────────────────────────────────────────────────────────
19+
20+
MEMORIES = f"{BASE_PATH}/memories"
21+
# POST MEMORIES → create memory
22+
# GET MEMORIES → list memories (with OData $filter / $top / $skip)
23+
# GET MEMORIES({id}) → get memory
24+
# PATCH MEMORIES({id}) → update memory
25+
# DELETE MEMORIES({id}) → delete memory
26+
27+
MEMORY_SEARCH = f"{MEMORIES}/search"
28+
# POST MEMORY_SEARCH → semantic similarity search
29+
30+
# ── Message endpoints ─────────────────────────────────────────────────────────
31+
32+
MESSAGES = f"{BASE_PATH}/messages"
33+
# POST MESSAGES → create message
34+
# GET MESSAGES → list messages (with OData $filter / $top / $skip)
35+
# GET MESSAGES({id}) → get message
36+
# DELETE MESSAGES({id}) → delete message (not updatable)
37+
38+
# ── Admin endpoints ───────────────────────────────────────────────────────────
39+
40+
ADMIN_BASE_PATH = "/v1/admin"
41+
42+
RETENTION_CONFIG = f"{ADMIN_BASE_PATH}/retentionConfig"
43+
# GET RETENTION_CONFIG → get singleton retention config
44+
# PATCH RETENTION_CONFIG → update retention policy
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""HTTP transport for the Agent Memory service.
2+
3+
Handles OAuth2 ``client_credentials`` token acquisition with lazy,
4+
expiry-aware caching. If ``token_url`` is not configured, requests are
5+
sent unauthenticated — expected for local development environments.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
from datetime import datetime, timedelta
12+
from typing import Any, Optional
13+
from urllib.parse import quote, urlencode
14+
15+
import requests
16+
from oauthlib.oauth2 import BackendApplicationClient
17+
from requests.exceptions import RequestException, Timeout
18+
from requests_oauthlib import OAuth2Session
19+
20+
from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig
21+
from sap_cloud_sdk.agent_memory.exceptions import (
22+
AgentMemoryHttpError,
23+
AgentMemoryNotFoundError,
24+
)
25+
26+
logger = logging.getLogger(__name__)
27+
28+
_TOKEN_EXPIRY_BUFFER_SECONDS = 60
29+
30+
31+
class HttpTransport:
32+
"""Internal HTTP transport for the Agent Memory service.
33+
34+
Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) and
35+
attaches the ``Authorization`` header to every request automatically via
36+
``OAuth2Session``. In no-auth mode (no ``token_url``), a plain
37+
``requests.Session`` is used instead.
38+
39+
Args:
40+
config: Service configuration.
41+
"""
42+
43+
def __init__(self, config: AgentMemoryConfig) -> None:
44+
self._config = config
45+
self._oauth: Optional[OAuth2Session] = None
46+
self._plain_session: Optional[requests.Session] = None
47+
self._token_expires_at: Optional[datetime] = None
48+
49+
def close(self) -> None:
50+
"""Close the underlying HTTP session(s) and release resources."""
51+
if self._oauth is not None:
52+
self._oauth.close()
53+
self._oauth = None
54+
if self._plain_session is not None:
55+
self._plain_session.close()
56+
self._plain_session = None
57+
58+
# ── Public HTTP methods ────────────────────────────────────────────────────
59+
60+
def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
61+
"""Perform a GET request.
62+
63+
Args:
64+
path: API path (appended to ``base_url``).
65+
params: Optional query parameters.
66+
67+
Returns:
68+
Parsed JSON response body.
69+
70+
Raises:
71+
AgentMemoryHttpError: On HTTP errors or network failures.
72+
AgentMemoryNotFoundError: If the server returns 404.
73+
"""
74+
return self._request("GET", path, params=params)
75+
76+
def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
77+
"""Perform a POST request.
78+
79+
Args:
80+
path: API path (appended to ``base_url``).
81+
json: Optional request body dict (serialised to JSON).
82+
83+
Returns:
84+
Parsed JSON response body. Returns an empty dict for 204 responses.
85+
86+
Raises:
87+
AgentMemoryHttpError: On HTTP errors or network failures.
88+
AgentMemoryNotFoundError: If the server returns 404.
89+
"""
90+
return self._request("POST", path, json=json)
91+
92+
def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
93+
"""Perform a PATCH request.
94+
95+
Args:
96+
path: API path (appended to ``base_url``).
97+
json: Optional request body dict (serialised to JSON).
98+
99+
Returns:
100+
Parsed JSON response body. Returns an empty dict for 204 responses.
101+
102+
Raises:
103+
AgentMemoryHttpError: On HTTP errors or network failures.
104+
AgentMemoryNotFoundError: If the server returns 404.
105+
"""
106+
return self._request("PATCH", path, json=json)
107+
108+
def delete(self, path: str) -> None:
109+
"""Perform a DELETE request.
110+
111+
Args:
112+
path: API path (appended to ``base_url``).
113+
114+
Raises:
115+
AgentMemoryHttpError: On HTTP errors or network failures.
116+
AgentMemoryNotFoundError: If the server returns 404.
117+
"""
118+
self._request("DELETE", path)
119+
120+
# ── Internal helpers ───────────────────────────────────────────────────────
121+
122+
def _get_session(self) -> requests.Session:
123+
"""Return a session ready to make requests.
124+
125+
In no-auth mode, returns a plain ``requests.Session`` (created once).
126+
In OAuth2 mode, returns an ``OAuth2Session`` with a valid token,
127+
fetching or refreshing the token if needed.
128+
"""
129+
if not self._config.token_url:
130+
if self._plain_session is None:
131+
self._plain_session = requests.Session()
132+
return self._plain_session
133+
134+
if (
135+
self._oauth is not None
136+
and self._token_expires_at is not None
137+
and datetime.now() < self._token_expires_at
138+
):
139+
return self._oauth
140+
141+
self._oauth = self._fetch_token()
142+
return self._oauth
143+
144+
def _fetch_token(self) -> OAuth2Session:
145+
"""Acquire a new OAuth2 ``client_credentials`` token.
146+
147+
Returns:
148+
An ``OAuth2Session`` with a valid token attached.
149+
150+
Raises:
151+
AgentMemoryHttpError: If the token endpoint returns an error or is unreachable.
152+
"""
153+
try:
154+
client = BackendApplicationClient(client_id=self._config.client_id)
155+
oauth = OAuth2Session(client=client)
156+
token = oauth.fetch_token(
157+
token_url=self._config.token_url,
158+
client_id=self._config.client_id,
159+
client_secret=self._config.client_secret,
160+
timeout=self._config.timeout,
161+
)
162+
except Exception as exc:
163+
raise AgentMemoryHttpError(f"Failed to obtain OAuth2 token: {exc}") from exc
164+
165+
expires_in: int = token.get("expires_in", 3600)
166+
self._token_expires_at = datetime.now() + timedelta(
167+
seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS
168+
)
169+
170+
if self._oauth is not None:
171+
self._oauth.close()
172+
173+
logger.debug(
174+
"Obtained new Agent Memory OAuth2 token (expires in %ds)", expires_in
175+
)
176+
return oauth
177+
178+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
179+
"""Execute an HTTP request using the appropriate session."""
180+
logger.debug("%s %s", method, path)
181+
182+
url = f"{self._config.base_url}{path}"
183+
if "params" in kwargs:
184+
raw_params: dict[str, Any] = kwargs.pop("params")
185+
if raw_params:
186+
url = f"{url}?{urlencode(raw_params, quote_via=quote)}"
187+
188+
session = self._get_session()
189+
headers = {"Content-Type": "application/json"}
190+
191+
try:
192+
response = session.request(
193+
method, url, headers=headers, timeout=self._config.timeout, **kwargs
194+
)
195+
except Timeout as exc:
196+
raise AgentMemoryHttpError(f"Request timed out: {method} {path}") from exc
197+
except RequestException as exc:
198+
raise AgentMemoryHttpError(
199+
f"Request failed: {method} {path}{exc}"
200+
) from exc
201+
202+
if response.status_code == 204 or not response.content:
203+
return {}
204+
205+
if response.status_code == 404:
206+
raise AgentMemoryNotFoundError(
207+
f"Resource not found: {method} {path}",
208+
status_code=404,
209+
response_text=response.text,
210+
)
211+
212+
if not response.ok:
213+
raise AgentMemoryHttpError(
214+
f"Agent Memory service request failed. "
215+
f"Method: {method}, Path: {path}, "
216+
f"Status: {response.status_code}, Response: {response.text}",
217+
status_code=response.status_code,
218+
response_text=response.text,
219+
)
220+
221+
return response.json()

0 commit comments

Comments
 (0)