Skip to content

Commit 7de0f24

Browse files
Merge branch 'main' into feat/enable-multitenancy-support-for-agent-memory
2 parents 7e27312 + d35af86 commit 7de0f24

28 files changed

Lines changed: 841 additions & 28 deletions

File tree

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.33.1"
3+
version = "0.35.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -33,6 +33,7 @@ dependencies = [
3333
extensibility = ["a2a-sdk>=0.2.0"]
3434
starlette = ["starlette>=0.40.0"]
3535
langchain = ["langchain-core>=1.2.7"]
36+
langgraph = ["langgraph>=1.0.0"]
3637

3738
[build-system]
3839
requires = ["hatchling"]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Factory subpackage for SAP Agent Memory framework adapters.
2+
3+
Each module in this package provides a factory function that creates the
4+
appropriate framework-specific implementation for the current environment.
5+
6+
Available factories:
7+
8+
- :func:`sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint.create_checkpointer`
9+
— returns a LangGraph ``BaseCheckpointSaver`` (short-term memory)
10+
11+
Usage:
12+
13+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer
14+
"""
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""TimedInMemorySaver — InMemorySaver with background TTL-based thread eviction.
2+
3+
Not intended for direct use. Use ``create_checkpointer(ttl_seconds=...)`` instead.
4+
"""
5+
6+
import logging
7+
import threading
8+
import time
9+
from typing import Any
10+
11+
from langchain_core.runnables import RunnableConfig
12+
from langgraph.checkpoint.base import ChannelVersions, Checkpoint, CheckpointMetadata
13+
from langgraph.checkpoint.memory import InMemorySaver
14+
15+
logger = logging.getLogger(__name__)
16+
17+
_SWEEP_INTERVAL_SECONDS = 60
18+
19+
20+
class TimedInMemorySaver(InMemorySaver):
21+
"""InMemorySaver with background TTL-based thread eviction.
22+
23+
Tracks last-active time per thread and evicts inactive threads via a
24+
daemon background sweep. The sweep is fully decoupled from the read/write
25+
path — ``put()`` only updates the activity timestamp.
26+
27+
This approximates server-side TTL semantics for in-process storage:
28+
- ``put()`` records last-active timestamp (hot path stays clean)
29+
- A daemon sweep thread evicts inactive threads every 60 seconds
30+
- Eviction is best-effort — a thread may live up to
31+
``ttl_seconds + 60`` seconds before deletion
32+
- State does not survive process restarts (inherent to InMemorySaver)
33+
34+
Args:
35+
ttl_seconds: Inactivity threshold in seconds. Threads inactive for
36+
longer than this are evicted. Default: 3600 (1 hour).
37+
38+
Example::
39+
40+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import (
41+
create_checkpointer,
42+
)
43+
44+
checkpointer = create_checkpointer(ttl_seconds=3600)
45+
"""
46+
47+
def __init__(self, *, ttl_seconds: int = 3600, **kwargs: Any) -> None:
48+
super().__init__(**kwargs)
49+
self.ttl_seconds = ttl_seconds
50+
self._last_active: dict[str, float] = {}
51+
self._lock = threading.Lock()
52+
self._sweeper = threading.Thread(
53+
target=self._sweep_loop,
54+
daemon=True,
55+
name="TimedInMemorySaver-sweeper",
56+
)
57+
self._sweeper.start()
58+
logger.debug(
59+
"TimedInMemorySaver started with ttl_seconds=%d, sweep_interval=%ds",
60+
ttl_seconds,
61+
_SWEEP_INTERVAL_SECONDS,
62+
)
63+
64+
def put(
65+
self,
66+
config: RunnableConfig,
67+
checkpoint: Checkpoint,
68+
metadata: CheckpointMetadata,
69+
new_versions: ChannelVersions,
70+
) -> RunnableConfig:
71+
"""Save checkpoint and refresh thread activity timestamp."""
72+
thread_id: str = config["configurable"]["thread_id"]
73+
with self._lock:
74+
self._last_active[thread_id] = time.monotonic()
75+
return super().put(config, checkpoint, metadata, new_versions)
76+
77+
async def aput(
78+
self,
79+
config: RunnableConfig,
80+
checkpoint: Checkpoint,
81+
metadata: CheckpointMetadata,
82+
new_versions: ChannelVersions,
83+
) -> RunnableConfig:
84+
"""Async version of put — refreshes thread activity timestamp."""
85+
thread_id: str = config["configurable"]["thread_id"]
86+
with self._lock:
87+
self._last_active[thread_id] = time.monotonic()
88+
return await super().aput(config, checkpoint, metadata, new_versions)
89+
90+
def _sweep_loop(self) -> None:
91+
while True:
92+
time.sleep(_SWEEP_INTERVAL_SECONDS)
93+
self._evict_expired()
94+
95+
def _evict_expired(self) -> None:
96+
now = time.monotonic()
97+
with self._lock:
98+
expired = [
99+
tid
100+
for tid, ts in self._last_active.items()
101+
if now - ts > self.ttl_seconds
102+
]
103+
for tid in expired:
104+
self.delete_thread(tid)
105+
with self._lock:
106+
self._last_active.pop(tid, None)
107+
logger.info(
108+
"TimedInMemorySaver: evicted inactive thread '%s' (inactive for >%ds)",
109+
tid,
110+
self.ttl_seconds,
111+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""LangGraph checkpointer factory for SAP Agent Memory.
2+
3+
Usage::
4+
5+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer
6+
7+
# No TTL — plain InMemorySaver
8+
checkpointer = create_checkpointer()
9+
10+
# With TTL — TimedInMemorySaver evicts inactive threads automatically
11+
checkpointer = create_checkpointer(ttl_seconds=3600)
12+
13+
app = workflow.compile(checkpointer=checkpointer)
14+
15+
# Or with LangChain create_agent:
16+
from langchain.agents import create_agent
17+
agent = create_agent(model="...", tools=[...], checkpointer=checkpointer)
18+
"""
19+
20+
import logging
21+
from typing import Optional
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
def create_checkpointer(*, ttl_seconds: Optional[int] = None):
27+
"""Create a LangGraph checkpointer for the current environment.
28+
29+
Returns LangGraph's ``InMemorySaver`` (no TTL) or ``TimedInMemorySaver``
30+
(with TTL-based thread eviction). State is held in-process and does not
31+
survive restarts.
32+
33+
Args:
34+
ttl_seconds: Evict threads inactive for this many seconds.
35+
``None`` (default) disables eviction.
36+
37+
Returns:
38+
BaseCheckpointSaver instance.
39+
40+
Raises:
41+
ImportError: If langgraph is not installed.
42+
43+
Example — no TTL::
44+
45+
checkpointer = create_checkpointer()
46+
app = workflow.compile(checkpointer=checkpointer)
47+
48+
Example — evict threads inactive for 1 hour::
49+
50+
checkpointer = create_checkpointer(ttl_seconds=3600)
51+
app = workflow.compile(checkpointer=checkpointer)
52+
"""
53+
try:
54+
from langgraph.checkpoint.memory import InMemorySaver
55+
except ImportError:
56+
raise ImportError(
57+
"langgraph is required for create_checkpointer(). "
58+
"Install it with: pip install sap-cloud-sdk[langgraph] or pip install langgraph"
59+
)
60+
61+
if ttl_seconds is not None:
62+
from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver
63+
64+
logger.warning(
65+
"create_checkpointer(): using TimedInMemorySaver(ttl_seconds=%d) — "
66+
"session state is in-process only and will be lost on process exit.",
67+
ttl_seconds,
68+
)
69+
return TimedInMemorySaver(ttl_seconds=ttl_seconds)
70+
71+
logger.warning(
72+
"create_checkpointer(): using InMemorySaver — "
73+
"session state is in-process only and will be lost on process exit."
74+
)
75+
return InMemorySaver()

‎src/sap_cloud_sdk/agent_memory/user-guide.md‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ plain text, and the service makes it searchable by meaning.
6666
- [Mounted Secrets (Kubernetes)](#mounted-secrets-kubernetes)
6767
- [Environment Variables](#environment-variables)
6868
- [UAA JSON Schema](#uaa-json-schema)
69+
- [LangGraph Checkpointer](#langgraph-checkpointer)
70+
- [Prerequisites](#prerequisites)
71+
- [Import](#import-1)
72+
- [Usage with LangGraph StateGraph](#usage-with-langgraph-stategraph)
73+
- [Usage with LangChain create\_agent](#usage-with-langchain-create_agent)
74+
- [Thread TTL](#thread-ttl)
6975

7076
## Installation
7177

@@ -898,3 +904,116 @@ The `uaa` key must contain a JSON string with the XSUAA credentials:
898904
"url": "https://subdomain.authentication.region.hana.ondemand.com"
899905
}
900906
```
907+
908+
## LangGraph Checkpointer
909+
910+
> This section covers the LangGraph-specific
911+
> factory for short-term memory (checkpointing). It is only relevant if your agent
912+
> is built with LangGraph or LangChain's `create_agent()`. The core
913+
> `AgentMemoryClient` and `create_client()` above are framework-agnostic and work
914+
> independently of this section.
915+
916+
For LangGraph agents, the `agent_memory` module provides a `create_checkpointer()`
917+
factory in the `factory` subpackage. It returns a `BaseCheckpointSaver` that
918+
manages short-term session memory — conversation state, thread continuity, and
919+
HITL support — natively within LangGraph.
920+
921+
> [!NOTE]
922+
> The current implementation uses LangGraph's `InMemorySaver` or
923+
> `TimedInMemorySaver` (when `ttl_seconds` is set). State is held in-process
924+
> and does not survive restarts. Persistent checkpointing backed by the
925+
> Agent Memory Service is not yet supported.
926+
927+
### Prerequisites
928+
929+
`langgraph` is an optional dependency. Install it via the SDK extra or directly:
930+
931+
```bash
932+
# Via SDK optional extra (recommended)
933+
pip install "sap-cloud-sdk[langgraph]"
934+
935+
# Or install langgraph directly
936+
pip install langgraph
937+
```
938+
939+
### Import
940+
941+
```python
942+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer
943+
```
944+
945+
### Usage with LangGraph StateGraph
946+
947+
```python
948+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer
949+
950+
checkpointer = create_checkpointer()
951+
app = workflow.compile(checkpointer=checkpointer)
952+
953+
result = app.invoke(
954+
{"messages": [{"role": "user", "content": "hello"}]},
955+
{"configurable": {"thread_id": "session-1"}},
956+
)
957+
```
958+
959+
### Usage with LangChain create_agent
960+
961+
```python
962+
from langchain.agents import create_agent
963+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer
964+
965+
agent = create_agent(
966+
model="...",
967+
tools=[...],
968+
checkpointer=create_checkpointer(),
969+
)
970+
```
971+
972+
### Thread TTL
973+
974+
Pass `ttl_seconds` to evict threads that have been inactive for the given
975+
period. This prevents unbounded memory growth in long-running processes.
976+
977+
```python
978+
# Evict threads inactive for more than 1 hour
979+
checkpointer = create_checkpointer(ttl_seconds=3600)
980+
```
981+
982+
When `ttl_seconds` is set, the factory returns a `TimedInMemorySaver` that
983+
tracks last-active time per thread and evicts inactive threads via a
984+
background daemon sweep. Eviction is best-effort — a thread may live up to
985+
`ttl_seconds + 60` seconds before deletion.
986+
987+
#### Exposing TTL as a configurable parameter with `@agent_config`
988+
989+
Use `@agent_config` from `sap_cloud_sdk.agent_decorators` to expose
990+
`ttl_seconds` as an operator-adjustable configuration field. The key
991+
`config.checkpointer.ttl_seconds` groups it with other checkpointer settings
992+
so external tooling can surface it in the low-code UI alongside model
993+
selection and temperature.
994+
995+
```python
996+
from sap_cloud_sdk.agent_decorators import agent_config
997+
from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer
998+
999+
1000+
@agent_config(
1001+
key="config.checkpointer.ttl_seconds",
1002+
label="Thread TTL (seconds)",
1003+
description="Evict inactive conversation threads after this period of "
1004+
"inactivity. Set to 0 to disable eviction.",
1005+
)
1006+
def thread_ttl_seconds() -> int:
1007+
return 3600 # 1 hour
1008+
1009+
1010+
class MyAgent:
1011+
def __init__(self):
1012+
ttl = thread_ttl_seconds()
1013+
self._checkpointer = create_checkpointer(ttl_seconds=ttl or None)
1014+
```
1015+
1016+
> [!NOTE]
1017+
> `TimedInMemorySaver` state does not survive process restarts — the TTL
1018+
> applies to in-process memory only. Persistent TTL enforcement will be
1019+
> available when the Agent Memory Service checkpointer ships.

‎src/sap_cloud_sdk/agentgateway/_fragments.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717

1818
from sap_cloud_sdk.agentgateway.exceptions import MCPServerNotFoundError
19+
from sap_cloud_sdk.core.telemetry import Module
1920

2021
logger = logging.getLogger(__name__)
2122

@@ -35,7 +36,10 @@ class FragmentLabel(str, Enum):
3536

3637

3738
def _list_fragments_by_label(label: FragmentLabel, tenant_subdomain: str) -> list:
38-
client = create_fragment_client(instance=_DESTINATION_INSTANCE)
39+
client = create_fragment_client(
40+
instance=_DESTINATION_INSTANCE,
41+
_telemetry_source=Module.AGENTGATEWAY,
42+
)
3943
return client.list_instance_fragments(
4044
filter=ListOptions(filter_labels=[Label(key=LABEL_KEY, values=[label.value])]),
4145
tenant=tenant_subdomain,

0 commit comments

Comments
 (0)