From 959b4f2b0989365717b3aa7e386e690a424a45e7 Mon Sep 17 00:00:00 2001 From: lailoo <1811866786@qq.com> Date: Mon, 9 Mar 2026 15:48:27 +0800 Subject: [PATCH] fix(checkpointer): return InMemorySaver instead of None when not configured (#1016) (#1019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(checkpointer): return InMemorySaver instead of None when not configured (#1016) * fix(checkpointer): also fix get_checkpointer() to return InMemorySaver Make all three checkpointer functions consistent: - make_checkpointer() (async) → InMemorySaver - checkpointer_context() (sync) → InMemorySaver - get_checkpointer() (sync singleton) → InMemorySaver This ensures DeerFlowClient always has a valid checkpointer. * fix: address CI failure and Copilot review feedback - Fix import order in test_checkpointer_none_fix.py (I001 ruff error) - Fix type annotation: _checkpointer should be Checkpointer | None - Update docstring: change "None if not configured" to "InMemorySaver if not configured" - Ensure app config is loaded before checking checkpointer config to prevent incorrect InMemorySaver fallback --------- Co-authored-by: Willem Jiang --- .../src/agents/checkpointer/async_provider.py | 10 ++-- backend/src/agents/checkpointer/provider.py | 34 +++++++++--- backend/tests/test_checkpointer.py | 9 +++- backend/tests/test_checkpointer_none_fix.py | 54 +++++++++++++++++++ 4 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_checkpointer_none_fix.py diff --git a/backend/src/agents/checkpointer/async_provider.py b/backend/src/agents/checkpointer/async_provider.py index 11dc295..028c306 100644 --- a/backend/src/agents/checkpointer/async_provider.py +++ b/backend/src/agents/checkpointer/async_provider.py @@ -10,7 +10,7 @@ Usage (e.g. FastAPI lifespan):: from src.agents.checkpointer.async_provider import make_checkpointer async with make_checkpointer() as checkpointer: - app.state.checkpointer = checkpointer # None if not configured + app.state.checkpointer = checkpointer # InMemorySaver if not configured For sync usage see :mod:`src.agents.checkpointer.provider`. """ @@ -87,20 +87,22 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]: @contextlib.asynccontextmanager -async def make_checkpointer() -> AsyncIterator[Checkpointer | None]: +async def make_checkpointer() -> AsyncIterator[Checkpointer]: """Async context manager that yields a checkpointer for the caller's lifetime. Resources are opened on enter and closed on exit — no global state:: async with make_checkpointer() as checkpointer: app.state.checkpointer = checkpointer - Yields ``None`` when no checkpointer is configured in *config.yaml*. + Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*. """ config = get_app_config() if config.checkpointer is None: - yield None + from langgraph.checkpoint.memory import InMemorySaver + + yield InMemorySaver() return async with _async_checkpointer(config.checkpointer) as saver: diff --git a/backend/src/agents/checkpointer/provider.py b/backend/src/agents/checkpointer/provider.py index 40032b7..c2dc002 100644 --- a/backend/src/agents/checkpointer/provider.py +++ b/backend/src/agents/checkpointer/provider.py @@ -107,14 +107,14 @@ def _sync_checkpointer_cm(config: CheckpointerConfig) -> Iterator[Checkpointer]: # Sync singleton # --------------------------------------------------------------------------- -_checkpointer: Checkpointer = None +_checkpointer: Checkpointer | None = None _checkpointer_ctx = None # open context manager keeping the connection alive -def get_checkpointer() -> Checkpointer | None: +def get_checkpointer() -> Checkpointer: """Return the global sync checkpointer singleton, creating it on first call. - Returns ``None`` when no checkpointer is configured in *config.yaml*. + Returns an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*. Raises: ImportError: If the required package for the configured backend is not installed. @@ -125,11 +125,29 @@ def get_checkpointer() -> Checkpointer | None: if _checkpointer is not None: return _checkpointer + # Ensure app config is loaded before checking checkpointer config + # This prevents returning InMemorySaver when config.yaml actually has a checkpointer section + # but hasn't been loaded yet + from src.config.app_config import _app_config from src.config.checkpointer_config import get_checkpointer_config + if _app_config is None: + # Only load config if it hasn't been initialized yet + # In tests, config may be set directly via set_checkpointer_config() + try: + get_app_config() + except FileNotFoundError: + # In test environments without config.yaml, this is expected + # Tests will set config directly via set_checkpointer_config() + pass + config = get_checkpointer_config() if config is None: - return None + from langgraph.checkpoint.memory import InMemorySaver + + logger.info("Checkpointer: using InMemorySaver (in-process, not persistent)") + _checkpointer = InMemorySaver() + return _checkpointer _checkpointer_ctx = _sync_checkpointer_cm(config) _checkpointer = _checkpointer_ctx.__enter__() @@ -159,7 +177,7 @@ def reset_checkpointer() -> None: @contextlib.contextmanager -def checkpointer_context() -> Iterator[Checkpointer | None]: +def checkpointer_context() -> Iterator[Checkpointer]: """Sync context manager that yields a checkpointer and cleans up on exit. Unlike :func:`get_checkpointer`, this does **not** cache the instance — @@ -168,11 +186,15 @@ def checkpointer_context() -> Iterator[Checkpointer | None]: with checkpointer_context() as cp: graph.invoke(input, config={"configurable": {"thread_id": "1"}}) + + Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*. """ config = get_app_config() if config.checkpointer is None: - yield None + from langgraph.checkpoint.memory import InMemorySaver + + yield InMemorySaver() return with _sync_checkpointer_cm(config.checkpointer) as saver: diff --git a/backend/tests/test_checkpointer.py b/backend/tests/test_checkpointer.py index 7a96557..59caf12 100644 --- a/backend/tests/test_checkpointer.py +++ b/backend/tests/test_checkpointer.py @@ -71,8 +71,13 @@ class TestCheckpointerConfig: class TestGetCheckpointer: - def test_returns_none_when_not_configured(self): - assert get_checkpointer() is None + def test_returns_in_memory_saver_when_not_configured(self): + """get_checkpointer should return InMemorySaver when not configured.""" + from langgraph.checkpoint.memory import InMemorySaver + + cp = get_checkpointer() + assert cp is not None + assert isinstance(cp, InMemorySaver) def test_memory_returns_in_memory_saver(self): load_checkpointer_config_from_dict({"type": "memory"}) diff --git a/backend/tests/test_checkpointer_none_fix.py b/backend/tests/test_checkpointer_none_fix.py new file mode 100644 index 0000000..091aa31 --- /dev/null +++ b/backend/tests/test_checkpointer_none_fix.py @@ -0,0 +1,54 @@ +"""Test for issue #1016: checkpointer should not return None.""" + +from unittest.mock import MagicMock, patch + +import pytest +from langgraph.checkpoint.memory import InMemorySaver + + +class TestCheckpointerNoneFix: + """Tests that checkpointer context managers return InMemorySaver instead of None.""" + + @pytest.mark.anyio + async def test_async_make_checkpointer_returns_in_memory_saver_when_not_configured(self): + """make_checkpointer should return InMemorySaver when config.checkpointer is None.""" + from src.agents.checkpointer.async_provider import make_checkpointer + + # Mock get_app_config to return a config with checkpointer=None + mock_config = MagicMock() + mock_config.checkpointer = None + + with patch("src.agents.checkpointer.async_provider.get_app_config", return_value=mock_config): + async with make_checkpointer() as checkpointer: + # Should return InMemorySaver, not None + assert checkpointer is not None + assert isinstance(checkpointer, InMemorySaver) + + # Should be able to call alist() without AttributeError + # This is what LangGraph does and what was failing in issue #1016 + result = [] + async for item in checkpointer.alist(config={"configurable": {"thread_id": "test"}}): + result.append(item) + + # Empty list is expected for a fresh checkpointer + assert result == [] + + def test_sync_checkpointer_context_returns_in_memory_saver_when_not_configured(self): + """checkpointer_context should return InMemorySaver when config.checkpointer is None.""" + from src.agents.checkpointer.provider import checkpointer_context + + # Mock get_app_config to return a config with checkpointer=None + mock_config = MagicMock() + mock_config.checkpointer = None + + with patch("src.agents.checkpointer.provider.get_app_config", return_value=mock_config): + with checkpointer_context() as checkpointer: + # Should return InMemorySaver, not None + assert checkpointer is not None + assert isinstance(checkpointer, InMemorySaver) + + # Should be able to call list() without AttributeError + result = list(checkpointer.list(config={"configurable": {"thread_id": "test"}})) + + # Empty list is expected for a fresh checkpointer + assert result == []