From 06981f89ee9fa9e5c2fb5f16e1b70da3e4b987b0 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 3 Jul 2026 15:13:24 +0530 Subject: [PATCH 01/21] feat(agent-memory): add create_checkpointer() factory for LangGraph agents Adds factory/langgraph_checkpoint.py providing create_checkpointer() which returns LangGraph's InMemorySaver. The factory subpackage is structured to support future framework adapters (store, crewai) without namespace conflicts with the langgraph package itself. Persistent checkpointing backed by the Agent Memory Service is not yet supported. AGENTMAAS-410 --- .../agent_memory/factory/__init__.py | 14 +++++ .../factory/langgraph_checkpoint.py | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/sap_cloud_sdk/agent_memory/factory/__init__.py create mode 100644 src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py diff --git a/src/sap_cloud_sdk/agent_memory/factory/__init__.py b/src/sap_cloud_sdk/agent_memory/factory/__init__.py new file mode 100644 index 00000000..6187c159 --- /dev/null +++ b/src/sap_cloud_sdk/agent_memory/factory/__init__.py @@ -0,0 +1,14 @@ +"""Factory subpackage for SAP Agent Memory framework adapters. + +Each module in this package provides a factory function that creates the +appropriate framework-specific implementation for the current environment. + +Available factories: + +- :func:`sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint.create_checkpointer` + — returns a LangGraph ``BaseCheckpointSaver`` (short-term memory) + +Usage:: + + from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer +""" diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py new file mode 100644 index 00000000..97bda865 --- /dev/null +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -0,0 +1,55 @@ +"""LangGraph checkpointer factory for SAP Agent Memory. + +Usage:: + + from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + + checkpointer = create_checkpointer() + app = workflow.compile(checkpointer=checkpointer) + + # Or with LangChain create_agent: + from langchain.agents import create_agent + agent = create_agent(model="...", tools=[...], checkpointer=checkpointer) +""" + +import logging + +logger = logging.getLogger(__name__) + + +def create_checkpointer(): + """Create a LangGraph checkpointer for the current environment. + + Returns LangGraph's ``InMemorySaver``. State is held in-process + and does not survive restarts. Persistent checkpointing backed by + the Agent Memory Service is not yet supported. + + Returns: + BaseCheckpointSaver instance. + + Raises: + ImportError: If langgraph is not installed. + + Example — compile a LangGraph workflow:: + + checkpointer = create_checkpointer() + app = workflow.compile(checkpointer=checkpointer) + result = app.invoke(input, {"configurable": {"thread_id": "abc"}}) + + Example — use with LangChain create_agent:: + + from langchain.agents import create_agent + agent = create_agent( + model="...", + tools=[...], + checkpointer=create_checkpointer(), + ) + """ + try: + from langgraph.checkpoint.memory import InMemorySaver + except ImportError: + raise ImportError( + "langgraph is required for create_checkpointer(). " + "Install it with: pip install langgraph" + ) + return InMemorySaver() From fb1642525280fd52ab1a46840af697e059a48047 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 3 Jul 2026 15:13:34 +0530 Subject: [PATCH 02/21] test(agent-memory): add unit tests for create_checkpointer() factory Covers return type, ImportError on missing langgraph, and three LangGraph integration tests verifying compile, state persistence, and thread isolation. AGENTMAAS-410 --- .../unit/test_langgraph_checkpointer.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/agent_memory/unit/test_langgraph_checkpointer.py diff --git a/tests/agent_memory/unit/test_langgraph_checkpointer.py b/tests/agent_memory/unit/test_langgraph_checkpointer.py new file mode 100644 index 00000000..234d7663 --- /dev/null +++ b/tests/agent_memory/unit/test_langgraph_checkpointer.py @@ -0,0 +1,101 @@ +"""Unit tests for the create_checkpointer() LangGraph factory.""" + +import builtins +from typing import Any, TypedDict +from unittest.mock import patch + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph + +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + + +class TestCreateCheckpointer: + """Tests for create_checkpointer() factory — AGENTMAAS-410.""" + + # ── Return type ─────────────────────────────────────────────────────────── + + def test_returns_in_memory_saver(self): + """Factory returns LangGraph's InMemorySaver.""" + result = create_checkpointer() + assert isinstance(result, InMemorySaver) + + # ── Missing langgraph ───────────────────────────────────────────────────── + + def test_missing_langgraph_raises_import_error(self): + """Clear ImportError when langgraph is not installed.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "langgraph.checkpoint.memory": + raise ImportError("No module named 'langgraph'") + return original_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=mock_import): + with pytest.raises(ImportError, match="langgraph is required"): + create_checkpointer() + + # ── LangGraph integration ───────────────────────────────────────────────── + + def test_checkpointer_compiles_with_langgraph_graph(self): + """Returned checkpointer can compile a LangGraph StateGraph.""" + class SimpleState(TypedDict): + value: str + + def noop(state: SimpleState) -> SimpleState: + return state + + builder = StateGraph(SimpleState) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + app: Any = builder.compile(checkpointer=create_checkpointer()) + assert app is not None + + def test_checkpointer_persists_state_across_invocations(self): + """State is preserved across invocations on the same thread_id.""" + class TickState(TypedDict): + values: list + + def append_node(state: TickState) -> TickState: + return {"values": state["values"] + ["tick"]} + + builder = StateGraph(TickState) # type: ignore + builder.add_node("append", append_node) + builder.add_edge(START, "append") + builder.add_edge("append", END) + + app: Any = builder.compile(checkpointer=create_checkpointer()) + config: RunnableConfig = {"configurable": {"thread_id": "test-thread-persist"}} + + result1 = app.invoke({"values": []}, config) + assert result1["values"] == ["tick"] + + result2 = app.invoke({"values": result1["values"]}, config) + assert result2["values"] == ["tick", "tick"] + + def test_different_thread_ids_are_isolated(self): + """Two thread IDs maintain independent state.""" + class NameState(TypedDict): + name: str + + def noop(state: NameState) -> NameState: + return state + + builder = StateGraph(NameState) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + app: Any = builder.compile(checkpointer=create_checkpointer()) + config_a: RunnableConfig = {"configurable": {"thread_id": "thread-isolation-a"}} + config_b: RunnableConfig = {"configurable": {"thread_id": "thread-isolation-b"}} + + app.invoke({"name": "alice"}, config_a) + app.invoke({"name": "bob"}, config_b) + + assert app.get_state(config_a).values["name"] == "alice" + assert app.get_state(config_b).values["name"] == "bob" From 563583f581815d3b2dacd395f02f61f26a717495 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 3 Jul 2026 15:13:46 +0530 Subject: [PATCH 03/21] docs(agent-memory): document LangGraph checkpointer factory in user guide Adds a LangGraph Checkpointer section distinguishing the framework adapter from the core service client. Notes that the current implementation uses InMemorySaver and does not support persistence yet. AGENTMAAS-410 --- src/sap_cloud_sdk/agent_memory/user-guide.md | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index caf98fc2..b47a85bd 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -57,6 +57,16 @@ plain text, and the service makes it searchable by meaning. - [`AgentMemoryNotFoundError` when fetching a resource](#agentmemorynotfounderror-when-fetching-a-resource) - [`AgentMemoryHttpError` with status 401](#agentmemoryhttperror-with-status-401) - [Configuration](#configuration) + - [Service Binding](#service-binding) + - [Mounted Secrets (Kubernetes)](#mounted-secrets-kubernetes) + - [Environment Variables](#environment-variables) + - [UAA JSON Schema](#uaa-json-schema) + - [LangGraph Checkpointer](#langgraph-checkpointer) + - [Prerequisites](#prerequisites) + - [Import](#import-1) + - [Usage with LangGraph StateGraph](#usage-with-langgraph-stategraph) + - [Usage with LangChain create\_agent](#usage-with-langchain-create_agent) + - [Local development](#local-development) ## Installation @@ -806,3 +816,63 @@ The `uaa` key must contain a JSON string with the XSUAA credentials: "url": "https://subdomain.authentication.region.hana.ondemand.com" } ``` + +## LangGraph Checkpointer + +> **Framework adapter — optional.** This section covers the LangGraph-specific +> factory for short-term memory (checkpointing). It is only relevant if your agent +> is built with LangGraph or LangChain's `create_agent()`. The core +> `AgentMemoryClient` and `create_client()` above are framework-agnostic and work +> independently of this section. + +For LangGraph agents, the `agent_memory` module provides a `create_checkpointer()` +factory in the `factory` subpackage. It returns a `BaseCheckpointSaver` that +manages short-term session memory — conversation state, thread continuity, and +HITL support — natively within LangGraph. + +> [!NOTE] +> The current implementation uses LangGraph's `InMemorySaver`. State is held +> in-process and does not survive restarts. Persistent checkpointing backed by +> the Agent Memory Service is not yet supported. + +### Prerequisites + +`langgraph` must be installed. The SDK does not declare it as a dependency — +you control your own LangGraph version. + +```bash +pip install langgraph +``` + +### Import + +```python +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer +``` + +### Usage with LangGraph StateGraph + +```python +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + +checkpointer = create_checkpointer() +app = workflow.compile(checkpointer=checkpointer) + +result = app.invoke( + {"messages": [{"role": "user", "content": "hello"}]}, + {"configurable": {"thread_id": "session-1"}}, +) +``` + +### Usage with LangChain create_agent + +```python +from langchain.agents import create_agent +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + +agent = create_agent( + model="...", + tools=[...], + checkpointer=create_checkpointer(), +) +``` From 03e957d8355086d48de69e1a447c2b07cdc507c3 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 3 Jul 2026 16:01:49 +0530 Subject: [PATCH 04/21] Remove unnecessary ticket reference from test docstring --- tests/agent_memory/unit/test_langgraph_checkpointer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agent_memory/unit/test_langgraph_checkpointer.py b/tests/agent_memory/unit/test_langgraph_checkpointer.py index 234d7663..8d215886 100644 --- a/tests/agent_memory/unit/test_langgraph_checkpointer.py +++ b/tests/agent_memory/unit/test_langgraph_checkpointer.py @@ -13,7 +13,7 @@ class TestCreateCheckpointer: - """Tests for create_checkpointer() factory — AGENTMAAS-410.""" + """Tests for create_checkpointer() factory.""" # ── Return type ─────────────────────────────────────────────────────────── From e9b83b0245a6a581440569b09d3abde861c3ebd7 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Mon, 6 Jul 2026 19:59:49 +0530 Subject: [PATCH 05/21] Add logger for in-memory checkpointer usage --- src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index 97bda865..d32d1cf0 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -52,4 +52,5 @@ def create_checkpointer(): "langgraph is required for create_checkpointer(). " "Install it with: pip install langgraph" ) + logger.info("Using in-memory checkpointer; state will not persist across restarts.") return InMemorySaver() From 36213a7fe637ed74b00cd7fcd27f51ec3b6950b5 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Mon, 6 Jul 2026 21:16:44 +0530 Subject: [PATCH 06/21] fix(agent-memory): upgrade InMemorySaver log from info to warning in create_checkpointer State loss on process exit is a relevant operational signal, not a routine info event. Message now names the function and describes the exact limitation. AGENTMAAS-410 --- .../agent_memory/factory/langgraph_checkpoint.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index d32d1cf0..adf9387a 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -52,5 +52,8 @@ def create_checkpointer(): "langgraph is required for create_checkpointer(). " "Install it with: pip install langgraph" ) - logger.info("Using in-memory checkpointer; state will not persist across restarts.") + logger.warning( + "create_checkpointer(): using InMemorySaver — " + "session state is in-process only and will be lost on process exit." + ) return InMemorySaver() From 28af3972052db36b10ced816946ea53437fac737 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Tue, 7 Jul 2026 20:52:02 +0530 Subject: [PATCH 07/21] feat(agent-memory): add TimedInMemorySaver with TTL and update factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TimedInMemorySaver extending InMemorySaver with a daemon background sweep thread that evicts inactive threads based on ttl_seconds. Updates create_checkpointer(ttl_seconds=...) factory to return TimedInMemorySaver when ttl_seconds is provided, or plain InMemorySaver otherwise. TimedInMemorySaver is a temporary home in the SDK factory subpackage. It will migrate to langgraph-checkpoint-sap-agent-memory in Phase 2 — no code change required for consumers of create_checkpointer(). AGENTMAAS-410 --- .../agent_memory/factory/_timed_memory.py | 111 ++++++++++++ .../factory/langgraph_checkpoint.py | 54 ++++-- tests/agent_memory/unit/test_timed_memory.py | 167 ++++++++++++++++++ 3 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py create mode 100644 tests/agent_memory/unit/test_timed_memory.py diff --git a/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py b/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py new file mode 100644 index 00000000..791d1c45 --- /dev/null +++ b/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py @@ -0,0 +1,111 @@ +"""TimedInMemorySaver — InMemorySaver with background TTL-based thread eviction. + +Not intended for direct use. Use ``create_checkpointer(ttl_seconds=...)`` instead. +""" + +import logging +import threading +import time +from typing import Any + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ChannelVersions, Checkpoint, CheckpointMetadata +from langgraph.checkpoint.memory import InMemorySaver + +logger = logging.getLogger(__name__) + +_SWEEP_INTERVAL_SECONDS = 60 + + +class TimedInMemorySaver(InMemorySaver): + """InMemorySaver with background TTL-based thread eviction. + + Tracks last-active time per thread and evicts inactive threads via a + daemon background sweep. The sweep is fully decoupled from the read/write + path — ``put()`` only updates the activity timestamp. + + This approximates server-side TTL semantics for in-process storage: + - ``put()`` records last-active timestamp (hot path stays clean) + - A daemon sweep thread evicts inactive threads every 60 seconds + - Eviction is best-effort — a thread may live up to + ``ttl_seconds + 60`` seconds before deletion + - State does not survive process restarts (inherent to InMemorySaver) + + Args: + ttl_seconds: Inactivity threshold in seconds. Threads inactive for + longer than this are evicted. Default: 3600 (1 hour). + + Example:: + + from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import ( + create_checkpointer, + ) + + checkpointer = create_checkpointer(ttl_seconds=3600) + """ + + def __init__(self, *, ttl_seconds: int = 3600, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.ttl_seconds = ttl_seconds + self._last_active: dict[str, float] = {} + self._lock = threading.Lock() + self._sweeper = threading.Thread( + target=self._sweep_loop, + daemon=True, + name="TimedInMemorySaver-sweeper", + ) + self._sweeper.start() + logger.debug( + "TimedInMemorySaver started with ttl_seconds=%d, sweep_interval=%ds", + ttl_seconds, + _SWEEP_INTERVAL_SECONDS, + ) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save checkpoint and refresh thread activity timestamp.""" + thread_id: str = config["configurable"]["thread_id"] + with self._lock: + self._last_active[thread_id] = time.monotonic() + return super().put(config, checkpoint, metadata, new_versions) + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Async version of put — refreshes thread activity timestamp.""" + thread_id: str = config["configurable"]["thread_id"] + with self._lock: + self._last_active[thread_id] = time.monotonic() + return await super().aput(config, checkpoint, metadata, new_versions) + + def _sweep_loop(self) -> None: + while True: + time.sleep(_SWEEP_INTERVAL_SECONDS) + self._evict_expired() + + def _evict_expired(self) -> None: + now = time.monotonic() + with self._lock: + expired = [ + tid + for tid, ts in self._last_active.items() + if now - ts > self.ttl_seconds + ] + for tid in expired: + self.delete_thread(tid) + with self._lock: + self._last_active.pop(tid, None) + logger.info( + "TimedInMemorySaver: evicted inactive thread '%s' (inactive for >%ds)", + tid, + self.ttl_seconds, + ) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index adf9387a..3043e457 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -4,7 +4,12 @@ from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + # No TTL — plain InMemorySaver checkpointer = create_checkpointer() + + # With TTL — TimedInMemorySaver evicts inactive threads automatically + checkpointer = create_checkpointer(ttl_seconds=3600) + app = workflow.compile(checkpointer=checkpointer) # Or with LangChain create_agent: @@ -13,16 +18,21 @@ """ import logging +from typing import Optional logger = logging.getLogger(__name__) -def create_checkpointer(): +def create_checkpointer(*, ttl_seconds: Optional[int] = None): """Create a LangGraph checkpointer for the current environment. - Returns LangGraph's ``InMemorySaver``. State is held in-process - and does not survive restarts. Persistent checkpointing backed by - the Agent Memory Service is not yet supported. + Returns LangGraph's ``InMemorySaver`` (no TTL) or + ``TimedInMemorySaver`` (with TTL-based thread eviction). + State is held in-process and does not survive restarts. + + Args: + ttl_seconds: Evict threads inactive for this many seconds. + ``None`` (default) disables eviction. Returns: BaseCheckpointSaver instance. @@ -30,20 +40,29 @@ def create_checkpointer(): Raises: ImportError: If langgraph is not installed. - Example — compile a LangGraph workflow:: + Example — no TTL:: checkpointer = create_checkpointer() app = workflow.compile(checkpointer=checkpointer) - result = app.invoke(input, {"configurable": {"thread_id": "abc"}}) - Example — use with LangChain create_agent:: + Example — evict threads inactive for 1 hour:: + + checkpointer = create_checkpointer(ttl_seconds=3600) + app = workflow.compile(checkpointer=checkpointer) + + Example — with @agent_config for centralised TTL configuration:: - from langchain.agents import create_agent - agent = create_agent( - model="...", - tools=[...], - checkpointer=create_checkpointer(), + from sap_cloud_sdk.agent_decorators import agent_config + + @agent_config( + key="config.thread_ttl_seconds", + label="Thread TTL (seconds)", + description="Evict inactive threads after this period", ) + def thread_ttl_seconds() -> int: + return 3600 + + checkpointer = create_checkpointer(ttl_seconds=thread_ttl_seconds()) """ try: from langgraph.checkpoint.memory import InMemorySaver @@ -52,6 +71,17 @@ def create_checkpointer(): "langgraph is required for create_checkpointer(). " "Install it with: pip install langgraph" ) + + if ttl_seconds is not None: + from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver + + logger.warning( + "create_checkpointer(): using TimedInMemorySaver(ttl_seconds=%d) — " + "session state is in-process only and will be lost on process exit.", + ttl_seconds, + ) + return TimedInMemorySaver(ttl_seconds=ttl_seconds) + logger.warning( "create_checkpointer(): using InMemorySaver — " "session state is in-process only and will be lost on process exit." diff --git a/tests/agent_memory/unit/test_timed_memory.py b/tests/agent_memory/unit/test_timed_memory.py new file mode 100644 index 00000000..9aa6b2b3 --- /dev/null +++ b/tests/agent_memory/unit/test_timed_memory.py @@ -0,0 +1,167 @@ +"""Unit tests for TimedInMemorySaver and create_checkpointer(ttl_seconds=...).""" + +import time +from typing import Any, TypedDict +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph + +from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + + +class TestCreateCheckpointerWithTTL: + """Tests for create_checkpointer(ttl_seconds=...) factory.""" + + def test_no_ttl_returns_in_memory_saver(self): + """Default returns plain InMemorySaver when ttl_seconds is None.""" + result = create_checkpointer() + assert isinstance(result, InMemorySaver) + assert not isinstance(result, TimedInMemorySaver) + + def test_ttl_returns_timed_in_memory_saver(self): + """ttl_seconds returns TimedInMemorySaver.""" + result = create_checkpointer(ttl_seconds=3600) + assert isinstance(result, TimedInMemorySaver) + + def test_ttl_value_is_set(self): + """ttl_seconds value is stored on the returned saver.""" + result = create_checkpointer(ttl_seconds=7200) + assert isinstance(result, TimedInMemorySaver) + assert result.ttl_seconds == 7200 + + def test_timed_saver_is_also_in_memory_saver(self): + """TimedInMemorySaver is a valid BaseCheckpointSaver subtype.""" + result = create_checkpointer(ttl_seconds=3600) + assert isinstance(result, InMemorySaver) + + def test_compiles_with_langgraph_graph(self): + """Timed checkpointer compiles a StateGraph correctly.""" + class State(TypedDict): + value: str + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + app: Any = builder.compile(checkpointer=create_checkpointer(ttl_seconds=3600)) + assert app is not None + + +class TestTimedInMemorySaver: + """Tests for TimedInMemorySaver behaviour.""" + + def test_default_ttl(self): + """Default TTL is 3600 seconds.""" + saver = TimedInMemorySaver() + assert saver.ttl_seconds == 3600 + + def test_custom_ttl(self): + """Custom TTL is stored correctly.""" + saver = TimedInMemorySaver(ttl_seconds=1800) + assert saver.ttl_seconds == 1800 + + def test_sweeper_thread_is_daemon(self): + """Background sweep thread is a daemon — dies with the process.""" + saver = TimedInMemorySaver() + assert saver._sweeper.daemon is True + assert saver._sweeper.is_alive() + + def test_put_updates_last_active(self): + """put() updates last-active timestamp for the thread.""" + class State(TypedDict): + x: int + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + saver = TimedInMemorySaver(ttl_seconds=3600) + app: Any = builder.compile(checkpointer=saver) + config: RunnableConfig = {"configurable": {"thread_id": "ttl-test-1"}} + + app.invoke({"x": 1}, config) + + assert "ttl-test-1" in saver._last_active + + def test_expired_thread_evicted(self): + """Threads inactive beyond ttl_seconds are evicted by _evict_expired.""" + class State(TypedDict): + x: int + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + saver = TimedInMemorySaver(ttl_seconds=1) + app: Any = builder.compile(checkpointer=saver) + config: RunnableConfig = {"configurable": {"thread_id": "evict-me"}} + + app.invoke({"x": 1}, config) + assert "evict-me" in saver._last_active + + # Backdate last-active to simulate 2 seconds of inactivity + with saver._lock: + saver._last_active["evict-me"] = time.monotonic() - 2 + + saver._evict_expired() + + assert "evict-me" not in saver._last_active + assert saver.storage.get("evict-me") is None + + def test_active_thread_not_evicted(self): + """Threads active within ttl_seconds are not evicted.""" + class State(TypedDict): + x: int + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + saver = TimedInMemorySaver(ttl_seconds=3600) + app: Any = builder.compile(checkpointer=saver) + config: RunnableConfig = {"configurable": {"thread_id": "keep-me"}} + + app.invoke({"x": 1}, config) + saver._evict_expired() + + assert "keep-me" in saver._last_active + assert saver.storage.get("keep-me") is not None + + def test_state_preserved_across_invocations(self): + """TimedInMemorySaver preserves state across invocations.""" + class State(TypedDict): + values: list + + def append_node(state: State) -> State: + return {"values": state["values"] + ["tick"]} + + builder = StateGraph(State) # type: ignore + builder.add_node("append", append_node) + builder.add_edge(START, "append") + builder.add_edge("append", END) + + app: Any = builder.compile(checkpointer=TimedInMemorySaver(ttl_seconds=3600)) + config: RunnableConfig = {"configurable": {"thread_id": "persist-test"}} + + result1 = app.invoke({"values": []}, config) + assert result1["values"] == ["tick"] + + result2 = app.invoke({"values": result1["values"]}, config) + assert result2["values"] == ["tick", "tick"] From ed6d8c48b9cb17c7351667f3d4276e5a7617c5ab Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Wed, 8 Jul 2026 14:18:27 +0530 Subject: [PATCH 08/21] docs(agent-memory): document ttl_seconds and TimedInMemorySaver in user guide Adds Thread TTL section to the LangGraph Checkpointer documentation covering ttl_seconds usage, TimedInMemorySaver eviction semantics, and the @agent_config decorator pattern for centralised TTL configuration. AGENTMAAS-410 --- src/sap_cloud_sdk/agent_memory/user-guide.md | 41 ++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index b47a85bd..6c602227 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -831,9 +831,10 @@ manages short-term session memory — conversation state, thread continuity, and HITL support — natively within LangGraph. > [!NOTE] -> The current implementation uses LangGraph's `InMemorySaver`. State is held -> in-process and does not survive restarts. Persistent checkpointing backed by -> the Agent Memory Service is not yet supported. +> The current implementation uses LangGraph's `InMemorySaver` or +> `TimedInMemorySaver` (when `ttl_seconds` is set). State is held in-process +> and does not survive restarts. Persistent checkpointing backed by the +> Agent Memory Service is not yet supported. ### Prerequisites @@ -876,3 +877,37 @@ agent = create_agent( checkpointer=create_checkpointer(), ) ``` + +### Thread TTL — evicting inactive threads + +Pass `ttl_seconds` to evict threads that have been inactive beyond the given +threshold. This prevents unbounded memory growth in long-running processes. + +```python +# Evict threads inactive for more than 1 hour +checkpointer = create_checkpointer(ttl_seconds=3600) +``` + +Returns a `TimedInMemorySaver` that tracks last-active time per thread and +evicts expired threads via a background sweep. Eviction is best-effort — +a thread may live up to `ttl_seconds + 60` seconds before deletion. + +**Using `@agent_config` for centralised TTL configuration:** + +```python +from sap_cloud_sdk.agent_decorators import agent_config +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + +@agent_config( + key="config.thread_ttl_seconds", + label="Thread TTL (seconds)", + description="Evict inactive conversation threads after this period of inactivity", +) +def thread_ttl_seconds() -> int: + return 3600 + +checkpointer = create_checkpointer(ttl_seconds=thread_ttl_seconds()) +``` + +This exposes TTL in the low-code UI alongside model selection and other +agent settings, allowing operators to adjust it without code changes. From 7ec8c838a300b16b97ba65090b3268e2328a2c30 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Thu, 9 Jul 2026 13:29:21 +0530 Subject: [PATCH 09/21] refactor(agent-memory): remove TimedInMemorySaver, retain ttl_seconds signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes TimedInMemorySaver and its tests. ttl_seconds is retained as a factory parameter for interface stability — TTL will be enforced by HanaAgentMemorySaver when available. InMemorySaver logs a clear warning when ttl_seconds is set explaining the current limitation. AGENTMAAS-410 --- .../agent_memory/factory/_timed_memory.py | 111 ------------ .../factory/langgraph_checkpoint.py | 25 ++- src/sap_cloud_sdk/agent_memory/user-guide.md | 20 +-- .../unit/test_langgraph_checkpointer.py | 15 ++ tests/agent_memory/unit/test_timed_memory.py | 167 ------------------ 5 files changed, 35 insertions(+), 303 deletions(-) delete mode 100644 src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py delete mode 100644 tests/agent_memory/unit/test_timed_memory.py diff --git a/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py b/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py deleted file mode 100644 index 791d1c45..00000000 --- a/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py +++ /dev/null @@ -1,111 +0,0 @@ -"""TimedInMemorySaver — InMemorySaver with background TTL-based thread eviction. - -Not intended for direct use. Use ``create_checkpointer(ttl_seconds=...)`` instead. -""" - -import logging -import threading -import time -from typing import Any - -from langchain_core.runnables import RunnableConfig -from langgraph.checkpoint.base import ChannelVersions, Checkpoint, CheckpointMetadata -from langgraph.checkpoint.memory import InMemorySaver - -logger = logging.getLogger(__name__) - -_SWEEP_INTERVAL_SECONDS = 60 - - -class TimedInMemorySaver(InMemorySaver): - """InMemorySaver with background TTL-based thread eviction. - - Tracks last-active time per thread and evicts inactive threads via a - daemon background sweep. The sweep is fully decoupled from the read/write - path — ``put()`` only updates the activity timestamp. - - This approximates server-side TTL semantics for in-process storage: - - ``put()`` records last-active timestamp (hot path stays clean) - - A daemon sweep thread evicts inactive threads every 60 seconds - - Eviction is best-effort — a thread may live up to - ``ttl_seconds + 60`` seconds before deletion - - State does not survive process restarts (inherent to InMemorySaver) - - Args: - ttl_seconds: Inactivity threshold in seconds. Threads inactive for - longer than this are evicted. Default: 3600 (1 hour). - - Example:: - - from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import ( - create_checkpointer, - ) - - checkpointer = create_checkpointer(ttl_seconds=3600) - """ - - def __init__(self, *, ttl_seconds: int = 3600, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.ttl_seconds = ttl_seconds - self._last_active: dict[str, float] = {} - self._lock = threading.Lock() - self._sweeper = threading.Thread( - target=self._sweep_loop, - daemon=True, - name="TimedInMemorySaver-sweeper", - ) - self._sweeper.start() - logger.debug( - "TimedInMemorySaver started with ttl_seconds=%d, sweep_interval=%ds", - ttl_seconds, - _SWEEP_INTERVAL_SECONDS, - ) - - def put( - self, - config: RunnableConfig, - checkpoint: Checkpoint, - metadata: CheckpointMetadata, - new_versions: ChannelVersions, - ) -> RunnableConfig: - """Save checkpoint and refresh thread activity timestamp.""" - thread_id: str = config["configurable"]["thread_id"] - with self._lock: - self._last_active[thread_id] = time.monotonic() - return super().put(config, checkpoint, metadata, new_versions) - - async def aput( - self, - config: RunnableConfig, - checkpoint: Checkpoint, - metadata: CheckpointMetadata, - new_versions: ChannelVersions, - ) -> RunnableConfig: - """Async version of put — refreshes thread activity timestamp.""" - thread_id: str = config["configurable"]["thread_id"] - with self._lock: - self._last_active[thread_id] = time.monotonic() - return await super().aput(config, checkpoint, metadata, new_versions) - - def _sweep_loop(self) -> None: - while True: - time.sleep(_SWEEP_INTERVAL_SECONDS) - self._evict_expired() - - def _evict_expired(self) -> None: - now = time.monotonic() - with self._lock: - expired = [ - tid - for tid, ts in self._last_active.items() - if now - ts > self.ttl_seconds - ] - for tid in expired: - self.delete_thread(tid) - with self._lock: - self._last_active.pop(tid, None) - logger.info( - "TimedInMemorySaver: evicted inactive thread '%s' (inactive for >%ds)", - tid, - self.ttl_seconds, - ) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index 3043e457..170bcba3 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -4,10 +4,10 @@ from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer - # No TTL — plain InMemorySaver + # No TTL checkpointer = create_checkpointer() - # With TTL — TimedInMemorySaver evicts inactive threads automatically + # With TTL — accepted now, enforced when HanaAgentMemorySaver is available checkpointer = create_checkpointer(ttl_seconds=3600) app = workflow.compile(checkpointer=checkpointer) @@ -26,13 +26,13 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): """Create a LangGraph checkpointer for the current environment. - Returns LangGraph's ``InMemorySaver`` (no TTL) or - ``TimedInMemorySaver`` (with TTL-based thread eviction). - State is held in-process and does not survive restarts. + Returns LangGraph's ``InMemorySaver``. State is held in-process + and does not survive restarts. Args: - ttl_seconds: Evict threads inactive for this many seconds. - ``None`` (default) disables eviction. + ttl_seconds: Thread TTL in seconds. Accepted for interface stability — + TTL enforcement will be active when ``HanaAgentMemorySaver`` + is available. Has no effect with ``InMemorySaver``. Returns: BaseCheckpointSaver instance. @@ -45,7 +45,7 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): checkpointer = create_checkpointer() app = workflow.compile(checkpointer=checkpointer) - Example — evict threads inactive for 1 hour:: + Example — with TTL (enforced in production, accepted but inactive locally):: checkpointer = create_checkpointer(ttl_seconds=3600) app = workflow.compile(checkpointer=checkpointer) @@ -73,14 +73,13 @@ def thread_ttl_seconds() -> int: ) if ttl_seconds is not None: - from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver - logger.warning( - "create_checkpointer(): using TimedInMemorySaver(ttl_seconds=%d) — " - "session state is in-process only and will be lost on process exit.", + "create_checkpointer(ttl_seconds=%d): TTL has no effect with " + "InMemorySaver. TTL will be enforced when HanaAgentMemorySaver " + "is available. Manage thread eviction manually via delete_thread() " + "for local development.", ttl_seconds, ) - return TimedInMemorySaver(ttl_seconds=ttl_seconds) logger.warning( "create_checkpointer(): using InMemorySaver — " diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 6c602227..4e3bd44f 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -831,10 +831,9 @@ manages short-term session memory — conversation state, thread continuity, and HITL support — natively within LangGraph. > [!NOTE] -> The current implementation uses LangGraph's `InMemorySaver` or -> `TimedInMemorySaver` (when `ttl_seconds` is set). State is held in-process -> and does not survive restarts. Persistent checkpointing backed by the -> Agent Memory Service is not yet supported. +> The current implementation uses LangGraph's `InMemorySaver`. State is held +> in-process and does not survive restarts. Persistent checkpointing backed by +> the Agent Memory Service is not yet supported. ### Prerequisites @@ -878,20 +877,17 @@ agent = create_agent( ) ``` -### Thread TTL — evicting inactive threads +### Thread TTL -Pass `ttl_seconds` to evict threads that have been inactive beyond the given -threshold. This prevents unbounded memory growth in long-running processes. +Pass `ttl_seconds` to declare the intended thread lifetime. The parameter is +accepted now for interface stability — TTL enforcement will be active when +`HanaAgentMemorySaver` is available. For local development, manage thread +eviction manually via `delete_thread()` if needed. ```python -# Evict threads inactive for more than 1 hour checkpointer = create_checkpointer(ttl_seconds=3600) ``` -Returns a `TimedInMemorySaver` that tracks last-active time per thread and -evicts expired threads via a background sweep. Eviction is best-effort — -a thread may live up to `ttl_seconds + 60` seconds before deletion. - **Using `@agent_config` for centralised TTL configuration:** ```python diff --git a/tests/agent_memory/unit/test_langgraph_checkpointer.py b/tests/agent_memory/unit/test_langgraph_checkpointer.py index 8d215886..90dfca7e 100644 --- a/tests/agent_memory/unit/test_langgraph_checkpointer.py +++ b/tests/agent_memory/unit/test_langgraph_checkpointer.py @@ -22,6 +22,21 @@ def test_returns_in_memory_saver(self): result = create_checkpointer() assert isinstance(result, InMemorySaver) + def test_ttl_seconds_still_returns_in_memory_saver(self): + """ttl_seconds is accepted but InMemorySaver is still returned.""" + result = create_checkpointer(ttl_seconds=3600) + assert isinstance(result, InMemorySaver) + + def test_ttl_seconds_logs_warning(self, caplog): + """ttl_seconds logs a warning that TTL has no effect locally.""" + import logging + with caplog.at_level( + logging.WARNING, + logger="sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint", + ): + create_checkpointer(ttl_seconds=3600) + assert "TTL has no effect" in caplog.text + # ── Missing langgraph ───────────────────────────────────────────────────── def test_missing_langgraph_raises_import_error(self): diff --git a/tests/agent_memory/unit/test_timed_memory.py b/tests/agent_memory/unit/test_timed_memory.py deleted file mode 100644 index 9aa6b2b3..00000000 --- a/tests/agent_memory/unit/test_timed_memory.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Unit tests for TimedInMemorySaver and create_checkpointer(ttl_seconds=...).""" - -import time -from typing import Any, TypedDict -from langchain_core.runnables import RunnableConfig -from langgraph.checkpoint.memory import InMemorySaver -from langgraph.graph import END, START, StateGraph - -from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver -from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer - - -class TestCreateCheckpointerWithTTL: - """Tests for create_checkpointer(ttl_seconds=...) factory.""" - - def test_no_ttl_returns_in_memory_saver(self): - """Default returns plain InMemorySaver when ttl_seconds is None.""" - result = create_checkpointer() - assert isinstance(result, InMemorySaver) - assert not isinstance(result, TimedInMemorySaver) - - def test_ttl_returns_timed_in_memory_saver(self): - """ttl_seconds returns TimedInMemorySaver.""" - result = create_checkpointer(ttl_seconds=3600) - assert isinstance(result, TimedInMemorySaver) - - def test_ttl_value_is_set(self): - """ttl_seconds value is stored on the returned saver.""" - result = create_checkpointer(ttl_seconds=7200) - assert isinstance(result, TimedInMemorySaver) - assert result.ttl_seconds == 7200 - - def test_timed_saver_is_also_in_memory_saver(self): - """TimedInMemorySaver is a valid BaseCheckpointSaver subtype.""" - result = create_checkpointer(ttl_seconds=3600) - assert isinstance(result, InMemorySaver) - - def test_compiles_with_langgraph_graph(self): - """Timed checkpointer compiles a StateGraph correctly.""" - class State(TypedDict): - value: str - - def noop(state: State) -> State: - return state - - builder = StateGraph(State) # type: ignore - builder.add_node("noop", noop) - builder.add_edge(START, "noop") - builder.add_edge("noop", END) - - app: Any = builder.compile(checkpointer=create_checkpointer(ttl_seconds=3600)) - assert app is not None - - -class TestTimedInMemorySaver: - """Tests for TimedInMemorySaver behaviour.""" - - def test_default_ttl(self): - """Default TTL is 3600 seconds.""" - saver = TimedInMemorySaver() - assert saver.ttl_seconds == 3600 - - def test_custom_ttl(self): - """Custom TTL is stored correctly.""" - saver = TimedInMemorySaver(ttl_seconds=1800) - assert saver.ttl_seconds == 1800 - - def test_sweeper_thread_is_daemon(self): - """Background sweep thread is a daemon — dies with the process.""" - saver = TimedInMemorySaver() - assert saver._sweeper.daemon is True - assert saver._sweeper.is_alive() - - def test_put_updates_last_active(self): - """put() updates last-active timestamp for the thread.""" - class State(TypedDict): - x: int - - def noop(state: State) -> State: - return state - - builder = StateGraph(State) # type: ignore - builder.add_node("noop", noop) - builder.add_edge(START, "noop") - builder.add_edge("noop", END) - - saver = TimedInMemorySaver(ttl_seconds=3600) - app: Any = builder.compile(checkpointer=saver) - config: RunnableConfig = {"configurable": {"thread_id": "ttl-test-1"}} - - app.invoke({"x": 1}, config) - - assert "ttl-test-1" in saver._last_active - - def test_expired_thread_evicted(self): - """Threads inactive beyond ttl_seconds are evicted by _evict_expired.""" - class State(TypedDict): - x: int - - def noop(state: State) -> State: - return state - - builder = StateGraph(State) # type: ignore - builder.add_node("noop", noop) - builder.add_edge(START, "noop") - builder.add_edge("noop", END) - - saver = TimedInMemorySaver(ttl_seconds=1) - app: Any = builder.compile(checkpointer=saver) - config: RunnableConfig = {"configurable": {"thread_id": "evict-me"}} - - app.invoke({"x": 1}, config) - assert "evict-me" in saver._last_active - - # Backdate last-active to simulate 2 seconds of inactivity - with saver._lock: - saver._last_active["evict-me"] = time.monotonic() - 2 - - saver._evict_expired() - - assert "evict-me" not in saver._last_active - assert saver.storage.get("evict-me") is None - - def test_active_thread_not_evicted(self): - """Threads active within ttl_seconds are not evicted.""" - class State(TypedDict): - x: int - - def noop(state: State) -> State: - return state - - builder = StateGraph(State) # type: ignore - builder.add_node("noop", noop) - builder.add_edge(START, "noop") - builder.add_edge("noop", END) - - saver = TimedInMemorySaver(ttl_seconds=3600) - app: Any = builder.compile(checkpointer=saver) - config: RunnableConfig = {"configurable": {"thread_id": "keep-me"}} - - app.invoke({"x": 1}, config) - saver._evict_expired() - - assert "keep-me" in saver._last_active - assert saver.storage.get("keep-me") is not None - - def test_state_preserved_across_invocations(self): - """TimedInMemorySaver preserves state across invocations.""" - class State(TypedDict): - values: list - - def append_node(state: State) -> State: - return {"values": state["values"] + ["tick"]} - - builder = StateGraph(State) # type: ignore - builder.add_node("append", append_node) - builder.add_edge(START, "append") - builder.add_edge("append", END) - - app: Any = builder.compile(checkpointer=TimedInMemorySaver(ttl_seconds=3600)) - config: RunnableConfig = {"configurable": {"thread_id": "persist-test"}} - - result1 = app.invoke({"values": []}, config) - assert result1["values"] == ["tick"] - - result2 = app.invoke({"values": result1["values"]}, config) - assert result2["values"] == ["tick", "tick"] From e7fb5d6e455612bb7a68805419a4cdcd99fb5df2 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Thu, 9 Jul 2026 14:13:54 +0530 Subject: [PATCH 10/21] docs(agent-memory): remove premature @agent_config TTL example The centralised TTL configuration via @agent_config is not yet available. Removed from both the factory docstring and the user guide. AGENTMAAS-410 --- .../factory/langgraph_checkpoint.py | 14 ------------- src/sap_cloud_sdk/agent_memory/user-guide.md | 20 ------------------- 2 files changed, 34 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index 170bcba3..2e02e802 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -49,20 +49,6 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): checkpointer = create_checkpointer(ttl_seconds=3600) app = workflow.compile(checkpointer=checkpointer) - - Example — with @agent_config for centralised TTL configuration:: - - from sap_cloud_sdk.agent_decorators import agent_config - - @agent_config( - key="config.thread_ttl_seconds", - label="Thread TTL (seconds)", - description="Evict inactive threads after this period", - ) - def thread_ttl_seconds() -> int: - return 3600 - - checkpointer = create_checkpointer(ttl_seconds=thread_ttl_seconds()) """ try: from langgraph.checkpoint.memory import InMemorySaver diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 4e3bd44f..0f3e7df5 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -887,23 +887,3 @@ eviction manually via `delete_thread()` if needed. ```python checkpointer = create_checkpointer(ttl_seconds=3600) ``` - -**Using `@agent_config` for centralised TTL configuration:** - -```python -from sap_cloud_sdk.agent_decorators import agent_config -from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer - -@agent_config( - key="config.thread_ttl_seconds", - label="Thread TTL (seconds)", - description="Evict inactive conversation threads after this period of inactivity", -) -def thread_ttl_seconds() -> int: - return 3600 - -checkpointer = create_checkpointer(ttl_seconds=thread_ttl_seconds()) -``` - -This exposes TTL in the low-code UI alongside model selection and other -agent settings, allowing operators to adjust it without code changes. From 6664b3642fe86029081fb72cf1bc4b5bb89a8b15 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Thu, 9 Jul 2026 17:41:13 +0530 Subject: [PATCH 11/21] feat(agent-memory): restore TimedInMemorySaver with TTL support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores TimedInMemorySaver — InMemorySaver with background daemon sweep for production-grade TTL-based thread eviction. create_checkpointer() returns TimedInMemorySaver when ttl_seconds is set, InMemorySaver otherwise. AGENTMAAS-410 --- .../agent_memory/factory/_timed_memory.py | 111 ++++++++++++ .../factory/langgraph_checkpoint.py | 25 +-- src/sap_cloud_sdk/agent_memory/user-guide.md | 7 +- .../unit/test_langgraph_checkpointer.py | 9 +- tests/agent_memory/unit/test_timed_memory.py | 167 ++++++++++++++++++ 5 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py create mode 100644 tests/agent_memory/unit/test_timed_memory.py diff --git a/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py b/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py new file mode 100644 index 00000000..791d1c45 --- /dev/null +++ b/src/sap_cloud_sdk/agent_memory/factory/_timed_memory.py @@ -0,0 +1,111 @@ +"""TimedInMemorySaver — InMemorySaver with background TTL-based thread eviction. + +Not intended for direct use. Use ``create_checkpointer(ttl_seconds=...)`` instead. +""" + +import logging +import threading +import time +from typing import Any + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ChannelVersions, Checkpoint, CheckpointMetadata +from langgraph.checkpoint.memory import InMemorySaver + +logger = logging.getLogger(__name__) + +_SWEEP_INTERVAL_SECONDS = 60 + + +class TimedInMemorySaver(InMemorySaver): + """InMemorySaver with background TTL-based thread eviction. + + Tracks last-active time per thread and evicts inactive threads via a + daemon background sweep. The sweep is fully decoupled from the read/write + path — ``put()`` only updates the activity timestamp. + + This approximates server-side TTL semantics for in-process storage: + - ``put()`` records last-active timestamp (hot path stays clean) + - A daemon sweep thread evicts inactive threads every 60 seconds + - Eviction is best-effort — a thread may live up to + ``ttl_seconds + 60`` seconds before deletion + - State does not survive process restarts (inherent to InMemorySaver) + + Args: + ttl_seconds: Inactivity threshold in seconds. Threads inactive for + longer than this are evicted. Default: 3600 (1 hour). + + Example:: + + from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import ( + create_checkpointer, + ) + + checkpointer = create_checkpointer(ttl_seconds=3600) + """ + + def __init__(self, *, ttl_seconds: int = 3600, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.ttl_seconds = ttl_seconds + self._last_active: dict[str, float] = {} + self._lock = threading.Lock() + self._sweeper = threading.Thread( + target=self._sweep_loop, + daemon=True, + name="TimedInMemorySaver-sweeper", + ) + self._sweeper.start() + logger.debug( + "TimedInMemorySaver started with ttl_seconds=%d, sweep_interval=%ds", + ttl_seconds, + _SWEEP_INTERVAL_SECONDS, + ) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save checkpoint and refresh thread activity timestamp.""" + thread_id: str = config["configurable"]["thread_id"] + with self._lock: + self._last_active[thread_id] = time.monotonic() + return super().put(config, checkpoint, metadata, new_versions) + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Async version of put — refreshes thread activity timestamp.""" + thread_id: str = config["configurable"]["thread_id"] + with self._lock: + self._last_active[thread_id] = time.monotonic() + return await super().aput(config, checkpoint, metadata, new_versions) + + def _sweep_loop(self) -> None: + while True: + time.sleep(_SWEEP_INTERVAL_SECONDS) + self._evict_expired() + + def _evict_expired(self) -> None: + now = time.monotonic() + with self._lock: + expired = [ + tid + for tid, ts in self._last_active.items() + if now - ts > self.ttl_seconds + ] + for tid in expired: + self.delete_thread(tid) + with self._lock: + self._last_active.pop(tid, None) + logger.info( + "TimedInMemorySaver: evicted inactive thread '%s' (inactive for >%ds)", + tid, + self.ttl_seconds, + ) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index 2e02e802..02d0ddb8 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -4,10 +4,10 @@ from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer - # No TTL + # No TTL — plain InMemorySaver checkpointer = create_checkpointer() - # With TTL — accepted now, enforced when HanaAgentMemorySaver is available + # With TTL — TimedInMemorySaver evicts inactive threads automatically checkpointer = create_checkpointer(ttl_seconds=3600) app = workflow.compile(checkpointer=checkpointer) @@ -26,13 +26,13 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): """Create a LangGraph checkpointer for the current environment. - Returns LangGraph's ``InMemorySaver``. State is held in-process - and does not survive restarts. + Returns LangGraph's ``InMemorySaver`` (no TTL) or ``TimedInMemorySaver`` + (with TTL-based thread eviction). State is held in-process and does not + survive restarts. Args: - ttl_seconds: Thread TTL in seconds. Accepted for interface stability — - TTL enforcement will be active when ``HanaAgentMemorySaver`` - is available. Has no effect with ``InMemorySaver``. + ttl_seconds: Evict threads inactive for this many seconds. + ``None`` (default) disables eviction. Returns: BaseCheckpointSaver instance. @@ -45,7 +45,7 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): checkpointer = create_checkpointer() app = workflow.compile(checkpointer=checkpointer) - Example — with TTL (enforced in production, accepted but inactive locally):: + Example — evict threads inactive for 1 hour:: checkpointer = create_checkpointer(ttl_seconds=3600) app = workflow.compile(checkpointer=checkpointer) @@ -59,13 +59,14 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): ) if ttl_seconds is not None: + from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver + logger.warning( - "create_checkpointer(ttl_seconds=%d): TTL has no effect with " - "InMemorySaver. TTL will be enforced when HanaAgentMemorySaver " - "is available. Manage thread eviction manually via delete_thread() " - "for local development.", + "create_checkpointer(): using TimedInMemorySaver(ttl_seconds=%d) — " + "session state is in-process only and will be lost on process exit.", ttl_seconds, ) + return TimedInMemorySaver(ttl_seconds=ttl_seconds) logger.warning( "create_checkpointer(): using InMemorySaver — " diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 0f3e7df5..1ed72719 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -831,9 +831,10 @@ manages short-term session memory — conversation state, thread continuity, and HITL support — natively within LangGraph. > [!NOTE] -> The current implementation uses LangGraph's `InMemorySaver`. State is held -> in-process and does not survive restarts. Persistent checkpointing backed by -> the Agent Memory Service is not yet supported. +> The current implementation uses LangGraph's `InMemorySaver` or +> `TimedInMemorySaver` (when `ttl_seconds` is set). State is held in-process +> and does not survive restarts. Persistent checkpointing backed by the +> Agent Memory Service is not yet supported. ### Prerequisites diff --git a/tests/agent_memory/unit/test_langgraph_checkpointer.py b/tests/agent_memory/unit/test_langgraph_checkpointer.py index 90dfca7e..5885961c 100644 --- a/tests/agent_memory/unit/test_langgraph_checkpointer.py +++ b/tests/agent_memory/unit/test_langgraph_checkpointer.py @@ -23,19 +23,20 @@ def test_returns_in_memory_saver(self): assert isinstance(result, InMemorySaver) def test_ttl_seconds_still_returns_in_memory_saver(self): - """ttl_seconds is accepted but InMemorySaver is still returned.""" + """ttl_seconds returns TimedInMemorySaver.""" + from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver result = create_checkpointer(ttl_seconds=3600) - assert isinstance(result, InMemorySaver) + assert isinstance(result, TimedInMemorySaver) def test_ttl_seconds_logs_warning(self, caplog): - """ttl_seconds logs a warning that TTL has no effect locally.""" + """ttl_seconds logs a warning about in-process state.""" import logging with caplog.at_level( logging.WARNING, logger="sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint", ): create_checkpointer(ttl_seconds=3600) - assert "TTL has no effect" in caplog.text + assert "TimedInMemorySaver" in caplog.text # ── Missing langgraph ───────────────────────────────────────────────────── diff --git a/tests/agent_memory/unit/test_timed_memory.py b/tests/agent_memory/unit/test_timed_memory.py new file mode 100644 index 00000000..9aa6b2b3 --- /dev/null +++ b/tests/agent_memory/unit/test_timed_memory.py @@ -0,0 +1,167 @@ +"""Unit tests for TimedInMemorySaver and create_checkpointer(ttl_seconds=...).""" + +import time +from typing import Any, TypedDict +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph + +from sap_cloud_sdk.agent_memory.factory._timed_memory import TimedInMemorySaver +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + + +class TestCreateCheckpointerWithTTL: + """Tests for create_checkpointer(ttl_seconds=...) factory.""" + + def test_no_ttl_returns_in_memory_saver(self): + """Default returns plain InMemorySaver when ttl_seconds is None.""" + result = create_checkpointer() + assert isinstance(result, InMemorySaver) + assert not isinstance(result, TimedInMemorySaver) + + def test_ttl_returns_timed_in_memory_saver(self): + """ttl_seconds returns TimedInMemorySaver.""" + result = create_checkpointer(ttl_seconds=3600) + assert isinstance(result, TimedInMemorySaver) + + def test_ttl_value_is_set(self): + """ttl_seconds value is stored on the returned saver.""" + result = create_checkpointer(ttl_seconds=7200) + assert isinstance(result, TimedInMemorySaver) + assert result.ttl_seconds == 7200 + + def test_timed_saver_is_also_in_memory_saver(self): + """TimedInMemorySaver is a valid BaseCheckpointSaver subtype.""" + result = create_checkpointer(ttl_seconds=3600) + assert isinstance(result, InMemorySaver) + + def test_compiles_with_langgraph_graph(self): + """Timed checkpointer compiles a StateGraph correctly.""" + class State(TypedDict): + value: str + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + app: Any = builder.compile(checkpointer=create_checkpointer(ttl_seconds=3600)) + assert app is not None + + +class TestTimedInMemorySaver: + """Tests for TimedInMemorySaver behaviour.""" + + def test_default_ttl(self): + """Default TTL is 3600 seconds.""" + saver = TimedInMemorySaver() + assert saver.ttl_seconds == 3600 + + def test_custom_ttl(self): + """Custom TTL is stored correctly.""" + saver = TimedInMemorySaver(ttl_seconds=1800) + assert saver.ttl_seconds == 1800 + + def test_sweeper_thread_is_daemon(self): + """Background sweep thread is a daemon — dies with the process.""" + saver = TimedInMemorySaver() + assert saver._sweeper.daemon is True + assert saver._sweeper.is_alive() + + def test_put_updates_last_active(self): + """put() updates last-active timestamp for the thread.""" + class State(TypedDict): + x: int + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + saver = TimedInMemorySaver(ttl_seconds=3600) + app: Any = builder.compile(checkpointer=saver) + config: RunnableConfig = {"configurable": {"thread_id": "ttl-test-1"}} + + app.invoke({"x": 1}, config) + + assert "ttl-test-1" in saver._last_active + + def test_expired_thread_evicted(self): + """Threads inactive beyond ttl_seconds are evicted by _evict_expired.""" + class State(TypedDict): + x: int + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + saver = TimedInMemorySaver(ttl_seconds=1) + app: Any = builder.compile(checkpointer=saver) + config: RunnableConfig = {"configurable": {"thread_id": "evict-me"}} + + app.invoke({"x": 1}, config) + assert "evict-me" in saver._last_active + + # Backdate last-active to simulate 2 seconds of inactivity + with saver._lock: + saver._last_active["evict-me"] = time.monotonic() - 2 + + saver._evict_expired() + + assert "evict-me" not in saver._last_active + assert saver.storage.get("evict-me") is None + + def test_active_thread_not_evicted(self): + """Threads active within ttl_seconds are not evicted.""" + class State(TypedDict): + x: int + + def noop(state: State) -> State: + return state + + builder = StateGraph(State) # type: ignore + builder.add_node("noop", noop) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + + saver = TimedInMemorySaver(ttl_seconds=3600) + app: Any = builder.compile(checkpointer=saver) + config: RunnableConfig = {"configurable": {"thread_id": "keep-me"}} + + app.invoke({"x": 1}, config) + saver._evict_expired() + + assert "keep-me" in saver._last_active + assert saver.storage.get("keep-me") is not None + + def test_state_preserved_across_invocations(self): + """TimedInMemorySaver preserves state across invocations.""" + class State(TypedDict): + values: list + + def append_node(state: State) -> State: + return {"values": state["values"] + ["tick"]} + + builder = StateGraph(State) # type: ignore + builder.add_node("append", append_node) + builder.add_edge(START, "append") + builder.add_edge("append", END) + + app: Any = builder.compile(checkpointer=TimedInMemorySaver(ttl_seconds=3600)) + config: RunnableConfig = {"configurable": {"thread_id": "persist-test"}} + + result1 = app.invoke({"values": []}, config) + assert result1["values"] == ["tick"] + + result2 = app.invoke({"values": result1["values"]}, config) + assert result2["values"] == ["tick", "tick"] From 33060068a111bdcfee95ee9445fe29af257e3c0a Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Thu, 9 Jul 2026 22:28:45 +0530 Subject: [PATCH 12/21] docs(agent-memory): update user guide for TimedInMemorySaver with TTL Documents ttl_seconds parameter, TimedInMemorySaver eviction semantics, and the process-exit limitation. Removes the placeholder text that said TTL has no effect. AGENTMAAS-410 --- src/sap_cloud_sdk/agent_memory/user-guide.md | 21 ++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 1ed72719..37cabd30 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -66,7 +66,7 @@ plain text, and the service makes it searchable by meaning. - [Import](#import-1) - [Usage with LangGraph StateGraph](#usage-with-langgraph-stategraph) - [Usage with LangChain create\_agent](#usage-with-langchain-create_agent) - - [Local development](#local-development) + - [Thread TTL](#thread-ttl) ## Installation @@ -819,7 +819,7 @@ The `uaa` key must contain a JSON string with the XSUAA credentials: ## LangGraph Checkpointer -> **Framework adapter — optional.** This section covers the LangGraph-specific +> This section covers the LangGraph-specific > factory for short-term memory (checkpointing). It is only relevant if your agent > is built with LangGraph or LangChain's `create_agent()`. The core > `AgentMemoryClient` and `create_client()` above are framework-agnostic and work @@ -880,11 +880,20 @@ agent = create_agent( ### Thread TTL -Pass `ttl_seconds` to declare the intended thread lifetime. The parameter is -accepted now for interface stability — TTL enforcement will be active when -`HanaAgentMemorySaver` is available. For local development, manage thread -eviction manually via `delete_thread()` if needed. +Pass `ttl_seconds` to evict threads that have been inactive for the given +period. This prevents unbounded memory growth in long-running processes. ```python +# Evict threads inactive for more than 1 hour checkpointer = create_checkpointer(ttl_seconds=3600) ``` + +When `ttl_seconds` is set, the factory returns a `TimedInMemorySaver` that +tracks last-active time per thread and evicts inactive threads via a +background daemon sweep. Eviction is best-effort — a thread may live up to +`ttl_seconds + 60` seconds before deletion. + +> [!NOTE] +> `TimedInMemorySaver` state does not survive process restarts — the TTL +> applies to in-process memory only. Persistent TTL enforcement will be +> available when the Agent Memory Service checkpointer ships. From 2df4371cc142efef0dd6875a6990177073d15462 Mon Sep 17 00:00:00 2001 From: santoshnallur Date: Fri, 10 Jul 2026 10:54:04 +0530 Subject: [PATCH 13/21] Apply review comment Co-authored-by: Nicole Gomes <47161082+NicoleMGomes@users.noreply.github.com> --- src/sap_cloud_sdk/agent_memory/factory/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/factory/__init__.py b/src/sap_cloud_sdk/agent_memory/factory/__init__.py index 6187c159..525b1b2f 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/factory/__init__.py @@ -8,7 +8,7 @@ - :func:`sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint.create_checkpointer` — returns a LangGraph ``BaseCheckpointSaver`` (short-term memory) -Usage:: +Usage: from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer """ From 19ba72ccde0fa14e7330524a8f517567a756651a Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 10 Jul 2026 11:06:16 +0530 Subject: [PATCH 14/21] feat(agent-memory): add langgraph optional dependency and update prerequisites Adds langgraph>=0.2.0 to [project.optional-dependencies] so consumers can install via pip install sap-cloud-sdk[langgraph]. Updates the user guide prerequisites section to document both installation paths. AGENTMAAS-410 --- pyproject.toml | 2 ++ src/sap_cloud_sdk/agent_memory/user-guide.md | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aa778f3e..48cd0581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,14 @@ dependencies = [ "opentelemetry-api>=1.42.1", "opentelemetry-sdk>=1.42.1", "mcp>=1.1.0", + "pre-commit>=4.6.0", ] [project.optional-dependencies] extensibility = ["a2a-sdk>=0.2.0"] starlette = ["starlette>=0.40.0"] langchain = ["langchain-core>=1.2.7"] +langgraph = ["langgraph>=0.2.0"] [build-system] requires = ["hatchling"] diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 37cabd30..50de5250 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -838,10 +838,13 @@ HITL support — natively within LangGraph. ### Prerequisites -`langgraph` must be installed. The SDK does not declare it as a dependency — -you control your own LangGraph version. +`langgraph` is an optional dependency. Install it via the SDK extra or directly: ```bash +# Via SDK optional extra (recommended) +pip install "sap-cloud-sdk[langgraph]" + +# Or install langgraph directly pip install langgraph ``` From 0c2bbbcb931e75771042fce8675962843b770c98 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 10 Jul 2026 11:08:13 +0530 Subject: [PATCH 15/21] fix(agent-memory): bump langgraph optional dep lower bound to >=1.0.0 LangGraph 1.0 introduced the stable public API and current import paths. Pre-1.0 versions are not supported. AGENTMAAS-410 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 48cd0581..63487b7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ extensibility = ["a2a-sdk>=0.2.0"] starlette = ["starlette>=0.40.0"] langchain = ["langchain-core>=1.2.7"] -langgraph = ["langgraph>=0.2.0"] +langgraph = ["langgraph>=1.0.0"] [build-system] requires = ["hatchling"] From 9a553cb10be3f3fc208291a8e4af5b84d9a0ee85 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 10 Jul 2026 14:50:34 +0530 Subject: [PATCH 16/21] docs(agent-memory): add @agent_config example for TTL configuration Shows how to expose ttl_seconds via agent_config decorator with key config.checkpointer.ttl_seconds for low-code UI integration. AGENTMAAS-410 --- src/sap_cloud_sdk/agent_memory/user-guide.md | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 50de5250..e396d3be 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -896,6 +896,35 @@ tracks last-active time per thread and evicts inactive threads via a background daemon sweep. Eviction is best-effort — a thread may live up to `ttl_seconds + 60` seconds before deletion. +**Exposing TTL as a configurable parameter with `@agent_config`:** + +Use `@agent_config` from `sap_cloud_sdk.agent_decorators` to expose +`ttl_seconds` as an operator-adjustable configuration field. The key +`config.checkpointer.ttl_seconds` groups it with other checkpointer settings +so external tooling can surface it in the low-code UI alongside model +selection and temperature. + +```python +from sap_cloud_sdk.agent_decorators import agent_config +from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_checkpointer + + +@agent_config( + key="config.checkpointer.ttl_seconds", + label="Thread TTL (seconds)", + description="Evict inactive conversation threads after this period of " + "inactivity. Set to 0 to disable eviction.", +) +def thread_ttl_seconds() -> int: + return 3600 + + +class MyAgent: + def __init__(self): + ttl = thread_ttl_seconds() + self._checkpointer = create_checkpointer(ttl_seconds=ttl or None) +``` + > [!NOTE] > `TimedInMemorySaver` state does not survive process restarts — the TTL > applies to in-process memory only. Persistent TTL enforcement will be From 08f01fa831b81ac5189583772e2358c531b8b5c8 Mon Sep 17 00:00:00 2001 From: Santosh Nallur Date: Fri, 10 Jul 2026 15:05:10 +0530 Subject: [PATCH 17/21] docs(agent-memory): update thread_ttl_seconds comment to clarify duration --- src/sap_cloud_sdk/agent_memory/user-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index e396d3be..6486cc44 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -916,7 +916,7 @@ from sap_cloud_sdk.agent_memory.factory.langgraph_checkpoint import create_check "inactivity. Set to 0 to disable eviction.", ) def thread_ttl_seconds() -> int: - return 3600 + return 3600 # 1 hour class MyAgent: From cc896e87d1a2b0d31a57f5e1b5ceba17cff53d37 Mon Sep 17 00:00:00 2001 From: santoshnallur Date: Fri, 10 Jul 2026 15:09:35 +0530 Subject: [PATCH 18/21] docs (agent-memory): update user-guide.md --- src/sap_cloud_sdk/agent_memory/user-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 6486cc44..7eb4d56a 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -896,7 +896,7 @@ tracks last-active time per thread and evicts inactive threads via a background daemon sweep. Eviction is best-effort — a thread may live up to `ttl_seconds + 60` seconds before deletion. -**Exposing TTL as a configurable parameter with `@agent_config`:** +#### Exposing TTL as a configurable parameter with `@agent_config` Use `@agent_config` from `sap_cloud_sdk.agent_decorators` to expose `ttl_seconds` as an operator-adjustable configuration field. The key From ed689ea2e37f6cf9ad98d79b3401b72b5987328b Mon Sep 17 00:00:00 2001 From: santoshnallur Date: Fri, 10 Jul 2026 15:24:25 +0530 Subject: [PATCH 19/21] revert pre-commit dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 63487b7b..a1342a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "opentelemetry-api>=1.42.1", "opentelemetry-sdk>=1.42.1", "mcp>=1.1.0", - "pre-commit>=4.6.0", ] [project.optional-dependencies] From e6e125aa55f04908ba8cc90b077be9dae4efac45 Mon Sep 17 00:00:00 2001 From: santoshnallur Date: Fri, 10 Jul 2026 15:27:24 +0530 Subject: [PATCH 20/21] feat(agent-memory): update langgraph installation instruction --- src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index 02d0ddb8..a9beecc1 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -55,7 +55,7 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): except ImportError: raise ImportError( "langgraph is required for create_checkpointer(). " - "Install it with: pip install langgraph" + "Install it with: pip install sap-cloud-sdk[langchain] or pip install langgraph" ) if ttl_seconds is not None: From 1673f0f7033d7f1c2ce86ccba6c52f4d5dce0664 Mon Sep 17 00:00:00 2001 From: santoshnallur Date: Fri, 10 Jul 2026 15:28:15 +0530 Subject: [PATCH 21/21] feat(agent-memory): update langgraph installation instruction --- src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py index a9beecc1..379e6e97 100644 --- a/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py +++ b/src/sap_cloud_sdk/agent_memory/factory/langgraph_checkpoint.py @@ -55,7 +55,7 @@ def create_checkpointer(*, ttl_seconds: Optional[int] = None): except ImportError: raise ImportError( "langgraph is required for create_checkpointer(). " - "Install it with: pip install sap-cloud-sdk[langchain] or pip install langgraph" + "Install it with: pip install sap-cloud-sdk[langgraph] or pip install langgraph" ) if ttl_seconds is not None: