From d664ae5a4b2e3ae34145f6dbdfc6908d955f6402 Mon Sep 17 00:00:00 2001 From: JeffJiang Date: Sat, 7 Mar 2026 21:07:21 +0800 Subject: [PATCH] Support langgraph checkpointer (#1005) * Add checkpointer configuration to config.example.yaml - Introduced a new section for checkpointer configuration to enable state persistence for the embedded DeerFlowClient. - Documented supported types: memory, sqlite, and postgres, along with examples for each. - Clarified that the LangGraph Server manages its own state persistence separately. * refactor(checkpointer): streamline checkpointer initialization and logging * fix(uv.lock): update revision and add new wheel URLs for brotlicffi package * feat: add langchain-anthropic dependency and update related configurations * Fix checkpointer lifecycle, docstring, and path resolution bugs from PR #1005 review (#4) * Initial plan * Address all review suggestions from PR #1005 Co-authored-by: foreleven <4785594+foreleven@users.noreply.github.com> * Fix resolve_path to always return real Path; move SQLite special-string handling to callers Co-authored-by: foreleven <4785594+foreleven@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: foreleven <4785594+foreleven@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: foreleven <4785594+foreleven@users.noreply.github.com> --- backend/langgraph.json | 3 + backend/pyproject.toml | 2 + backend/src/agents/__init__.py | 3 +- backend/src/agents/checkpointer/__init__.py | 9 + .../src/agents/checkpointer/async_provider.py | 107 ++++++++ backend/src/agents/checkpointer/provider.py | 179 ++++++++++++ backend/src/client.py | 53 ++-- backend/src/config/app_config.py | 6 + backend/src/config/checkpointer_config.py | 46 ++++ backend/src/config/paths.py | 12 + backend/tests/test_checkpointer.py | 255 ++++++++++++++++++ backend/tests/test_client_live.py | 58 ++-- backend/uv.lock | 136 ++++++++-- config.example.yaml | 34 +++ 14 files changed, 819 insertions(+), 84 deletions(-) create mode 100644 backend/src/agents/checkpointer/__init__.py create mode 100644 backend/src/agents/checkpointer/async_provider.py create mode 100644 backend/src/agents/checkpointer/provider.py create mode 100644 backend/src/config/checkpointer_config.py create mode 100644 backend/tests/test_checkpointer.py diff --git a/backend/langgraph.json b/backend/langgraph.json index c89eeef..e34d0b9 100644 --- a/backend/langgraph.json +++ b/backend/langgraph.json @@ -6,5 +6,8 @@ "env": ".env", "graphs": { "lead_agent": "src.agents:make_lead_agent" + }, + "checkpointer": { + "path": "./src/agents/checkpointer/async_provider.py:make_checkpointer" } } \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8a21c9e..f901c6b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "httpx>=0.28.0", "kubernetes>=30.0.0", "langchain>=1.2.3", + "langchain-anthropic>=1.3.4", "langchain-deepseek>=1.0.1", "langchain-mcp-adapters>=0.1.0", "langchain-openai>=1.1.7", @@ -32,6 +33,7 @@ dependencies = [ "ddgs>=9.10.0", "duckdb>=1.4.4", "langchain-google-genai>=4.2.1", + "langgraph-checkpoint-sqlite>=3.0.3", ] [dependency-groups] diff --git a/backend/src/agents/__init__.py b/backend/src/agents/__init__.py index 3bed203..cd0cb66 100644 --- a/backend/src/agents/__init__.py +++ b/backend/src/agents/__init__.py @@ -1,4 +1,5 @@ +from .checkpointer import get_checkpointer, make_checkpointer, reset_checkpointer from .lead_agent import make_lead_agent from .thread_state import SandboxState, ThreadState -__all__ = ["make_lead_agent", "SandboxState", "ThreadState"] +__all__ = ["make_lead_agent", "SandboxState", "ThreadState", "get_checkpointer", "reset_checkpointer", "make_checkpointer"] diff --git a/backend/src/agents/checkpointer/__init__.py b/backend/src/agents/checkpointer/__init__.py new file mode 100644 index 0000000..7bb0019 --- /dev/null +++ b/backend/src/agents/checkpointer/__init__.py @@ -0,0 +1,9 @@ +from .async_provider import make_checkpointer +from .provider import checkpointer_context, get_checkpointer, reset_checkpointer + +__all__ = [ + "get_checkpointer", + "reset_checkpointer", + "checkpointer_context", + "make_checkpointer", +] diff --git a/backend/src/agents/checkpointer/async_provider.py b/backend/src/agents/checkpointer/async_provider.py new file mode 100644 index 0000000..11dc295 --- /dev/null +++ b/backend/src/agents/checkpointer/async_provider.py @@ -0,0 +1,107 @@ +"""Async checkpointer factory. + +Provides an **async context manager** for long-running async servers that need +proper resource cleanup. + +Supported backends: memory, sqlite, postgres. + +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 + +For sync usage see :mod:`src.agents.checkpointer.provider`. +""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import AsyncIterator + +from langgraph.types import Checkpointer + +from src.agents.checkpointer.provider import ( + POSTGRES_CONN_REQUIRED, + POSTGRES_INSTALL, + SQLITE_INSTALL, + _resolve_sqlite_conn_str, +) +from src.config.app_config import get_app_config + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Async factory +# --------------------------------------------------------------------------- + + +@contextlib.asynccontextmanager +async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]: + """Async context manager that constructs and tears down a checkpointer.""" + if config.type == "memory": + from langgraph.checkpoint.memory import InMemorySaver + + yield InMemorySaver() + return + + if config.type == "sqlite": + try: + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + except ImportError as exc: + raise ImportError(SQLITE_INSTALL) from exc + + import pathlib + + conn_str = _resolve_sqlite_conn_str(config.connection_string or "store.db") + # Only create parent directories for real filesystem paths + if conn_str != ":memory:" and not conn_str.startswith("file:"): + pathlib.Path(conn_str).parent.mkdir(parents=True, exist_ok=True) + async with AsyncSqliteSaver.from_conn_string(conn_str) as saver: + await saver.setup() + yield saver + return + + if config.type == "postgres": + try: + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + except ImportError as exc: + raise ImportError(POSTGRES_INSTALL) from exc + + if not config.connection_string: + raise ValueError(POSTGRES_CONN_REQUIRED) + + async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver: + await saver.setup() + yield saver + return + + raise ValueError(f"Unknown checkpointer type: {config.type!r}") + + +# --------------------------------------------------------------------------- +# Public async context manager +# --------------------------------------------------------------------------- + + +@contextlib.asynccontextmanager +async def make_checkpointer() -> AsyncIterator[Checkpointer | None]: + """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*. + """ + + config = get_app_config() + + if config.checkpointer is None: + yield None + return + + async with _async_checkpointer(config.checkpointer) as saver: + yield saver diff --git a/backend/src/agents/checkpointer/provider.py b/backend/src/agents/checkpointer/provider.py new file mode 100644 index 0000000..40032b7 --- /dev/null +++ b/backend/src/agents/checkpointer/provider.py @@ -0,0 +1,179 @@ +"""Sync checkpointer factory. + +Provides a **sync singleton** and a **sync context manager** for LangGraph +graph compilation and CLI tools. + +Supported backends: memory, sqlite, postgres. + +Usage:: + + from src.agents.checkpointer.provider import get_checkpointer, checkpointer_context + + # Singleton — reused across calls, closed on process exit + cp = get_checkpointer() + + # One-shot — fresh connection, closed on block exit + with checkpointer_context() as cp: + graph.invoke(input, config={"configurable": {"thread_id": "1"}}) +""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import Iterator + +from langgraph.types import Checkpointer + +from src.config.app_config import get_app_config +from src.config.checkpointer_config import CheckpointerConfig +from src.config.paths import resolve_path + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Error message constants — imported by aio.provider too +# --------------------------------------------------------------------------- + +SQLITE_INSTALL = "langgraph-checkpoint-sqlite is required for the SQLite checkpointer. Install it with: uv add langgraph-checkpoint-sqlite" +POSTGRES_INSTALL = "langgraph-checkpoint-postgres is required for the PostgreSQL checkpointer. Install it with: uv add langgraph-checkpoint-postgres psycopg[binary] psycopg-pool" +POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend" + +# --------------------------------------------------------------------------- +# Sync factory +# --------------------------------------------------------------------------- + + +def _resolve_sqlite_conn_str(raw: str) -> str: + """Return a SQLite connection string ready for use with ``SqliteSaver``. + + SQLite special strings (``":memory:"`` and ``file:`` URIs) are returned + unchanged. Plain filesystem paths — relative or absolute — are resolved + to an absolute string via :func:`resolve_path`. + """ + if raw == ":memory:" or raw.startswith("file:"): + return raw + return str(resolve_path(raw)) + + +@contextlib.contextmanager +def _sync_checkpointer_cm(config: CheckpointerConfig) -> Iterator[Checkpointer]: + """Context manager that creates and tears down a sync checkpointer. + + Returns a configured ``Checkpointer`` instance. Resource cleanup for any + underlying connections or pools is handled by higher-level helpers in + this module (such as the singleton factory or context manager); this + function does not return a separate cleanup callback. + """ + if config.type == "memory": + from langgraph.checkpoint.memory import InMemorySaver + + logger.info("Checkpointer: using InMemorySaver (in-process, not persistent)") + yield InMemorySaver() + return + + if config.type == "sqlite": + try: + from langgraph.checkpoint.sqlite import SqliteSaver + except ImportError as exc: + raise ImportError(SQLITE_INSTALL) from exc + + conn_str = _resolve_sqlite_conn_str(config.connection_string or "store.db") + with SqliteSaver.from_conn_string(conn_str) as saver: + saver.setup() + logger.info("Checkpointer: using SqliteSaver (%s)", conn_str) + yield saver + return + + if config.type == "postgres": + try: + from langgraph.checkpoint.postgres import PostgresSaver + except ImportError as exc: + raise ImportError(POSTGRES_INSTALL) from exc + + if not config.connection_string: + raise ValueError(POSTGRES_CONN_REQUIRED) + + with PostgresSaver.from_conn_string(config.connection_string) as saver: + saver.setup() + logger.info("Checkpointer: using PostgresSaver") + yield saver + return + + raise ValueError(f"Unknown checkpointer type: {config.type!r}") + + +# --------------------------------------------------------------------------- +# Sync singleton +# --------------------------------------------------------------------------- + +_checkpointer: Checkpointer = None +_checkpointer_ctx = None # open context manager keeping the connection alive + + +def get_checkpointer() -> Checkpointer | None: + """Return the global sync checkpointer singleton, creating it on first call. + + Returns ``None`` when no checkpointer is configured in *config.yaml*. + + Raises: + ImportError: If the required package for the configured backend is not installed. + ValueError: If ``connection_string`` is missing for a backend that requires it. + """ + global _checkpointer, _checkpointer_ctx + + if _checkpointer is not None: + return _checkpointer + + from src.config.checkpointer_config import get_checkpointer_config + + config = get_checkpointer_config() + if config is None: + return None + + _checkpointer_ctx = _sync_checkpointer_cm(config) + _checkpointer = _checkpointer_ctx.__enter__() + + return _checkpointer + + +def reset_checkpointer() -> None: + """Reset the sync singleton, forcing recreation on the next call. + + Closes any open backend connections and clears the cached instance. + Useful in tests or after a configuration change. + """ + global _checkpointer, _checkpointer_ctx + if _checkpointer_ctx is not None: + try: + _checkpointer_ctx.__exit__(None, None, None) + except Exception: + logger.warning("Error during checkpointer cleanup", exc_info=True) + _checkpointer_ctx = None + _checkpointer = None + + +# --------------------------------------------------------------------------- +# Sync context manager +# --------------------------------------------------------------------------- + + +@contextlib.contextmanager +def checkpointer_context() -> Iterator[Checkpointer | None]: + """Sync context manager that yields a checkpointer and cleans up on exit. + + Unlike :func:`get_checkpointer`, this does **not** cache the instance — + each ``with`` block creates and destroys its own connection. Use it in + CLI scripts or tests where you want deterministic cleanup:: + + with checkpointer_context() as cp: + graph.invoke(input, config={"configurable": {"thread_id": "1"}}) + """ + + config = get_app_config() + if config.checkpointer is None: + yield None + return + + with _sync_checkpointer_cm(config.checkpointer) as saver: + yield saver diff --git a/backend/src/client.py b/backend/src/client.py index 010cbd7..480f75e 100644 --- a/backend/src/client.py +++ b/backend/src/client.py @@ -152,7 +152,10 @@ class DeerFlowClient: def _atomic_write_json(path: Path, data: dict) -> None: """Write JSON to *path* atomically (temp file + replace).""" fd = tempfile.NamedTemporaryFile( - mode="w", dir=path.parent, suffix=".tmp", delete=False, + mode="w", + dir=path.parent, + suffix=".tmp", + delete=False, ) try: json.dump(data, fd, indent=2) @@ -205,8 +208,13 @@ class DeerFlowClient: ), "state_schema": ThreadState, } - if self._checkpointer is not None: - kwargs["checkpointer"] = self._checkpointer + checkpointer = self._checkpointer + if checkpointer is None: + from src.agents.checkpointer import get_checkpointer + + checkpointer = get_checkpointer() + if checkpointer is not None: + kwargs["checkpointer"] = checkpointer self._agent = create_agent(**kwargs) self._agent_config_key = key @@ -320,10 +328,7 @@ class DeerFlowClient: "type": "ai", "content": "", "id": msg_id, - "tool_calls": [ - {"name": tc["name"], "args": tc["args"], "id": tc.get("id")} - for tc in msg.tool_calls - ], + "tool_calls": [{"name": tc["name"], "args": tc["args"], "id": tc.get("id")} for tc in msg.tool_calls], }, ) @@ -494,10 +499,7 @@ class DeerFlowClient: """ config_path = ExtensionsConfig.resolve_config_path() if config_path is None: - raise FileNotFoundError( - "Cannot locate extensions_config.json. " - "Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root." - ) + raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.") current_config = get_extensions_config() @@ -561,10 +563,7 @@ class DeerFlowClient: config_path = ExtensionsConfig.resolve_config_path() if config_path is None: - raise FileNotFoundError( - "Cannot locate extensions_config.json. " - "Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root." - ) + raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.") extensions_config = get_extensions_config() extensions_config.skills[name] = SkillStateConfig(enabled=enabled) @@ -739,7 +738,6 @@ class DeerFlowClient: uploaded_files: list[dict] = [] for src_path in resolved_files: - dest = uploads_dir / src_path.name shutil.copy2(src_path, dest) @@ -756,6 +754,7 @@ class DeerFlowClient: try: asyncio.get_running_loop() import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as pool: md_path = pool.submit(lambda: asyncio.run(convert_file_to_markdown(dest))).result() except RuntimeError: @@ -795,15 +794,17 @@ class DeerFlowClient: for fp in sorted(uploads_dir.iterdir()): if fp.is_file(): stat = fp.stat() - files.append({ - "filename": fp.name, - "size": str(stat.st_size), - "path": str(fp), - "virtual_path": f"/mnt/user-data/uploads/{fp.name}", - "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{fp.name}", - "extension": fp.suffix, - "modified": stat.st_mtime, - }) + files.append( + { + "filename": fp.name, + "size": str(stat.st_size), + "path": str(fp), + "virtual_path": f"/mnt/user-data/uploads/{fp.name}", + "artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{fp.name}", + "extension": fp.suffix, + "modified": stat.st_mtime, + } + ) return {"files": files, "count": len(files)} def delete_upload(self, thread_id: str, filename: str) -> dict: @@ -858,7 +859,7 @@ class DeerFlowClient: if not clean_path.startswith(virtual_prefix): raise ValueError(f"Path must start with /{virtual_prefix}") - relative = clean_path[len(virtual_prefix):].lstrip("/") + relative = clean_path[len(virtual_prefix) :].lstrip("/") base_dir = get_paths().sandbox_user_data_dir(thread_id) actual = (base_dir / relative).resolve() diff --git a/backend/src/config/app_config.py b/backend/src/config/app_config.py index 2232115..5b716e9 100644 --- a/backend/src/config/app_config.py +++ b/backend/src/config/app_config.py @@ -6,6 +6,7 @@ import yaml from dotenv import load_dotenv from pydantic import BaseModel, ConfigDict, Field +from src.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from src.config.extensions_config import ExtensionsConfig from src.config.memory_config import load_memory_config_from_dict from src.config.model_config import ModelConfig @@ -29,6 +30,7 @@ class AppConfig(BaseModel): skills: SkillsConfig = Field(default_factory=SkillsConfig, description="Skills configuration") extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig, description="Extensions configuration (MCP servers and skills state)") model_config = ConfigDict(extra="allow", frozen=False) + checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") @classmethod def resolve_config_path(cls, config_path: str | None = None) -> Path: @@ -92,6 +94,10 @@ class AppConfig(BaseModel): if "subagents" in config_data: load_subagents_config_from_dict(config_data["subagents"]) + # Load checkpointer config if present + if "checkpointer" in config_data: + load_checkpointer_config_from_dict(config_data["checkpointer"]) + # Load extensions config separately (it's in a different file) extensions_config = ExtensionsConfig.from_file() config_data["extensions"] = extensions_config.model_dump() diff --git a/backend/src/config/checkpointer_config.py b/backend/src/config/checkpointer_config.py new file mode 100644 index 0000000..6947cef --- /dev/null +++ b/backend/src/config/checkpointer_config.py @@ -0,0 +1,46 @@ +"""Configuration for LangGraph checkpointer.""" + +from typing import Literal + +from pydantic import BaseModel, Field + +CheckpointerType = Literal["memory", "sqlite", "postgres"] + + +class CheckpointerConfig(BaseModel): + """Configuration for LangGraph state persistence checkpointer.""" + + type: CheckpointerType = Field( + description="Checkpointer backend type. " + "'memory' is in-process only (lost on restart). " + "'sqlite' persists to a local file (requires langgraph-checkpoint-sqlite). " + "'postgres' persists to PostgreSQL (requires langgraph-checkpoint-postgres)." + ) + connection_string: str | None = Field( + default=None, + description="Connection string for sqlite (file path) or postgres (DSN). " + "Required for sqlite and postgres types. " + "For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. " + "For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.", + ) + + +# Global configuration instance — None means no checkpointer is configured. +_checkpointer_config: CheckpointerConfig | None = None + + +def get_checkpointer_config() -> CheckpointerConfig | None: + """Get the current checkpointer configuration, or None if not configured.""" + return _checkpointer_config + + +def set_checkpointer_config(config: CheckpointerConfig | None) -> None: + """Set the checkpointer configuration.""" + global _checkpointer_config + _checkpointer_config = config + + +def load_checkpointer_config_from_dict(config_dict: dict) -> None: + """Load checkpointer configuration from a dictionary.""" + global _checkpointer_config + _checkpointer_config = CheckpointerConfig(**config_dict) diff --git a/backend/src/config/paths.py b/backend/src/config/paths.py index 1c48c69..1a02842 100644 --- a/backend/src/config/paths.py +++ b/backend/src/config/paths.py @@ -176,3 +176,15 @@ def get_paths() -> Paths: if _paths is None: _paths = Paths() return _paths + + +def resolve_path(path: str) -> Path: + """Resolve *path* to an absolute ``Path``. + + Relative paths are resolved relative to the application base directory. + Absolute paths are returned as-is (after normalisation). + """ + p = Path(path) + if not p.is_absolute(): + p = get_paths().base_dir / path + return p.resolve() diff --git a/backend/tests/test_checkpointer.py b/backend/tests/test_checkpointer.py new file mode 100644 index 0000000..7a96557 --- /dev/null +++ b/backend/tests/test_checkpointer.py @@ -0,0 +1,255 @@ +"""Unit tests for checkpointer config and singleton factory.""" + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from src.agents.checkpointer import get_checkpointer, reset_checkpointer +from src.config.checkpointer_config import ( + CheckpointerConfig, + get_checkpointer_config, + load_checkpointer_config_from_dict, + set_checkpointer_config, +) + + +@pytest.fixture(autouse=True) +def reset_state(): + """Reset singleton state before each test.""" + set_checkpointer_config(None) + reset_checkpointer() + yield + set_checkpointer_config(None) + reset_checkpointer() + + +# --------------------------------------------------------------------------- +# Config tests +# --------------------------------------------------------------------------- + + +class TestCheckpointerConfig: + def test_load_memory_config(self): + load_checkpointer_config_from_dict({"type": "memory"}) + config = get_checkpointer_config() + assert config is not None + assert config.type == "memory" + assert config.connection_string is None + + def test_load_sqlite_config(self): + load_checkpointer_config_from_dict({"type": "sqlite", "connection_string": "/tmp/test.db"}) + config = get_checkpointer_config() + assert config is not None + assert config.type == "sqlite" + assert config.connection_string == "/tmp/test.db" + + def test_load_postgres_config(self): + load_checkpointer_config_from_dict({"type": "postgres", "connection_string": "postgresql://localhost/db"}) + config = get_checkpointer_config() + assert config is not None + assert config.type == "postgres" + assert config.connection_string == "postgresql://localhost/db" + + def test_default_connection_string_is_none(self): + config = CheckpointerConfig(type="memory") + assert config.connection_string is None + + def test_set_config_to_none(self): + load_checkpointer_config_from_dict({"type": "memory"}) + set_checkpointer_config(None) + assert get_checkpointer_config() is None + + def test_invalid_type_raises(self): + with pytest.raises(Exception): + load_checkpointer_config_from_dict({"type": "unknown"}) + + +# --------------------------------------------------------------------------- +# Factory tests +# --------------------------------------------------------------------------- + + +class TestGetCheckpointer: + def test_returns_none_when_not_configured(self): + assert get_checkpointer() is None + + def test_memory_returns_in_memory_saver(self): + load_checkpointer_config_from_dict({"type": "memory"}) + from langgraph.checkpoint.memory import InMemorySaver + + cp = get_checkpointer() + assert isinstance(cp, InMemorySaver) + + def test_memory_singleton(self): + load_checkpointer_config_from_dict({"type": "memory"}) + cp1 = get_checkpointer() + cp2 = get_checkpointer() + assert cp1 is cp2 + + def test_reset_clears_singleton(self): + load_checkpointer_config_from_dict({"type": "memory"}) + cp1 = get_checkpointer() + reset_checkpointer() + cp2 = get_checkpointer() + assert cp1 is not cp2 + + def test_sqlite_raises_when_package_missing(self): + load_checkpointer_config_from_dict({"type": "sqlite", "connection_string": "/tmp/test.db"}) + with patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": None}): + reset_checkpointer() + with pytest.raises(ImportError, match="langgraph-checkpoint-sqlite"): + get_checkpointer() + + def test_postgres_raises_when_package_missing(self): + load_checkpointer_config_from_dict({"type": "postgres", "connection_string": "postgresql://localhost/db"}) + with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": None}): + reset_checkpointer() + with pytest.raises(ImportError, match="langgraph-checkpoint-postgres"): + get_checkpointer() + + def test_postgres_raises_when_connection_string_missing(self): + load_checkpointer_config_from_dict({"type": "postgres"}) + mock_saver = MagicMock() + mock_module = MagicMock() + mock_module.PostgresSaver = mock_saver + with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_module}): + reset_checkpointer() + with pytest.raises(ValueError, match="connection_string is required"): + get_checkpointer() + + def test_sqlite_creates_saver(self): + """SQLite checkpointer is created when package is available.""" + load_checkpointer_config_from_dict({"type": "sqlite", "connection_string": "/tmp/test.db"}) + + mock_saver_instance = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__ = MagicMock(return_value=mock_saver_instance) + mock_cm.__exit__ = MagicMock(return_value=False) + + mock_saver_cls = MagicMock() + mock_saver_cls.from_conn_string = MagicMock(return_value=mock_cm) + + mock_module = MagicMock() + mock_module.SqliteSaver = mock_saver_cls + + with patch.dict(sys.modules, {"langgraph.checkpoint.sqlite": mock_module}): + reset_checkpointer() + cp = get_checkpointer() + + assert cp is mock_saver_instance + mock_saver_cls.from_conn_string.assert_called_once() + mock_saver_instance.setup.assert_called_once() + + def test_postgres_creates_saver(self): + """Postgres checkpointer is created when packages are available.""" + load_checkpointer_config_from_dict({"type": "postgres", "connection_string": "postgresql://localhost/db"}) + + mock_saver_instance = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__ = MagicMock(return_value=mock_saver_instance) + mock_cm.__exit__ = MagicMock(return_value=False) + + mock_saver_cls = MagicMock() + mock_saver_cls.from_conn_string = MagicMock(return_value=mock_cm) + + mock_pg_module = MagicMock() + mock_pg_module.PostgresSaver = mock_saver_cls + + with patch.dict(sys.modules, {"langgraph.checkpoint.postgres": mock_pg_module}): + reset_checkpointer() + cp = get_checkpointer() + + assert cp is mock_saver_instance + mock_saver_cls.from_conn_string.assert_called_once_with("postgresql://localhost/db") + mock_saver_instance.setup.assert_called_once() + + +# --------------------------------------------------------------------------- +# app_config.py integration +# --------------------------------------------------------------------------- + + +class TestAppConfigLoadsCheckpointer: + def test_load_checkpointer_section(self): + """load_checkpointer_config_from_dict populates the global config.""" + set_checkpointer_config(None) + load_checkpointer_config_from_dict({"type": "memory"}) + cfg = get_checkpointer_config() + assert cfg is not None + assert cfg.type == "memory" + + +# --------------------------------------------------------------------------- +# DeerFlowClient falls back to config checkpointer +# --------------------------------------------------------------------------- + + +class TestClientCheckpointerFallback: + def test_client_uses_config_checkpointer_when_none_provided(self): + """DeerFlowClient._ensure_agent falls back to get_checkpointer() when checkpointer=None.""" + from langgraph.checkpoint.memory import InMemorySaver + + from src.client import DeerFlowClient + + load_checkpointer_config_from_dict({"type": "memory"}) + + captured_kwargs = {} + + def fake_create_agent(**kwargs): + captured_kwargs.update(kwargs) + return MagicMock() + + model_mock = MagicMock() + config_mock = MagicMock() + config_mock.models = [model_mock] + config_mock.get_model_config.return_value = MagicMock(supports_vision=False) + config_mock.checkpointer = None + + with ( + patch("src.client.get_app_config", return_value=config_mock), + patch("src.client.create_agent", side_effect=fake_create_agent), + patch("src.client.create_chat_model", return_value=MagicMock()), + patch("src.client._build_middlewares", return_value=[]), + patch("src.client.apply_prompt_template", return_value=""), + patch("src.client.DeerFlowClient._get_tools", return_value=[]), + ): + client = DeerFlowClient(checkpointer=None) + config = client._get_runnable_config("test-thread") + client._ensure_agent(config) + + assert "checkpointer" in captured_kwargs + assert isinstance(captured_kwargs["checkpointer"], InMemorySaver) + + def test_client_explicit_checkpointer_takes_precedence(self): + """An explicitly provided checkpointer is used even when config checkpointer is set.""" + from src.client import DeerFlowClient + + load_checkpointer_config_from_dict({"type": "memory"}) + + explicit_cp = MagicMock() + captured_kwargs = {} + + def fake_create_agent(**kwargs): + captured_kwargs.update(kwargs) + return MagicMock() + + model_mock = MagicMock() + config_mock = MagicMock() + config_mock.models = [model_mock] + config_mock.get_model_config.return_value = MagicMock(supports_vision=False) + config_mock.checkpointer = None + + with ( + patch("src.client.get_app_config", return_value=config_mock), + patch("src.client.create_agent", side_effect=fake_create_agent), + patch("src.client.create_chat_model", return_value=MagicMock()), + patch("src.client._build_middlewares", return_value=[]), + patch("src.client.apply_prompt_template", return_value=""), + patch("src.client.DeerFlowClient._get_tools", return_value=[]), + ): + client = DeerFlowClient(checkpointer=explicit_cp) + config = client._get_runnable_config("test-thread") + client._ensure_agent(config) + + assert captured_kwargs["checkpointer"] is explicit_cp diff --git a/backend/tests/test_client_live.py b/backend/tests/test_client_live.py index 46f1a9c..f2caed3 100644 --- a/backend/tests/test_client_live.py +++ b/backend/tests/test_client_live.py @@ -28,6 +28,7 @@ if _skip_reason: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(scope="module") def client(): """Create a real DeerFlowClient (no mocks).""" @@ -38,6 +39,7 @@ def client(): def thread_tmp(tmp_path): """Provide a unique thread_id + tmp directory for file operations.""" import uuid + tid = f"live-test-{uuid.uuid4().hex[:8]}" return tid, tmp_path @@ -46,6 +48,7 @@ def thread_tmp(tmp_path): # Scenario 1: Basic chat — model responds coherently # =========================================================================== + class TestLiveBasicChat: def test_chat_returns_nonempty_string(self, client): """chat() returns a non-empty response from the real model.""" @@ -65,6 +68,7 @@ class TestLiveBasicChat: # Scenario 2: Streaming — events arrive in correct order # =========================================================================== + class TestLiveStreaming: def test_stream_yields_messages_tuple_and_end(self, client): """stream() produces at least one messages-tuple event and ends with end.""" @@ -81,10 +85,7 @@ class TestLiveStreaming: def test_stream_ai_content_nonempty(self, client): """Streamed messages-tuple AI events contain non-empty content.""" - ai_messages = [ - e for e in client.stream("What color is the sky? One word.") - if e.type == "messages-tuple" and e.data.get("type") == "ai" and e.data.get("content") - ] + ai_messages = [e for e in client.stream("What color is the sky? One word.") if e.type == "messages-tuple" and e.data.get("type") == "ai" and e.data.get("content")] assert len(ai_messages) >= 1 for m in ai_messages: assert len(m.data.get("content", "")) > 0 @@ -94,13 +95,11 @@ class TestLiveStreaming: # Scenario 3: Tool use — agent calls a tool and returns result # =========================================================================== + class TestLiveToolUse: def test_agent_uses_bash_tool(self, client): """Agent uses bash tool when asked to run a command.""" - events = list(client.stream( - "Use the bash tool to run: echo 'LIVE_TEST_OK'. " - "Then tell me the output." - )) + events = list(client.stream("Use the bash tool to run: echo 'LIVE_TEST_OK'. Then tell me the output.")) types = [e.type for e in events] print(f" event types: {types}") @@ -122,10 +121,7 @@ class TestLiveToolUse: def test_agent_uses_ls_tool(self, client): """Agent uses ls tool to list a directory.""" - events = list(client.stream( - "Use the ls tool to list the contents of /mnt/user-data/workspace. " - "Just report what you see." - )) + events = list(client.stream("Use the ls tool to list the contents of /mnt/user-data/workspace. Just report what you see.")) types = [e.type for e in events] print(f" event types: {types}") @@ -139,15 +135,11 @@ class TestLiveToolUse: # Scenario 4: Multi-tool chain — agent chains tools in sequence # =========================================================================== + class TestLiveMultiToolChain: def test_write_then_read(self, client): """Agent writes a file, then reads it back.""" - events = list(client.stream( - "Step 1: Use write_file to write 'integration_test_content' to " - "/mnt/user-data/outputs/live_test.txt. " - "Step 2: Use read_file to read that file back. " - "Step 3: Tell me the content you read." - )) + events = list(client.stream("Step 1: Use write_file to write 'integration_test_content' to /mnt/user-data/outputs/live_test.txt. Step 2: Use read_file to read that file back. Step 3: Tell me the content you read.")) types = [e.type for e in events] print(f" event types: {types}") @@ -164,16 +156,14 @@ class TestLiveMultiToolChain: ai_events = [e for e in events if e.type == "messages-tuple" and e.data.get("type") == "ai" and e.data.get("content")] tr_events = [e for e in events if e.type == "messages-tuple" and e.data.get("type") == "tool"] final_text = ai_events[-1].data["content"] if ai_events else "" - assert "integration_test_content" in final_text.lower() or any( - "integration_test_content" in e.data.get("content", "") - for e in tr_events - ) + assert "integration_test_content" in final_text.lower() or any("integration_test_content" in e.data.get("content", "") for e in tr_events) # =========================================================================== # Scenario 5: File upload lifecycle with real filesystem # =========================================================================== + class TestLiveFileUpload: def test_upload_list_delete(self, client, thread_tmp): """Upload → list → delete → verify deletion.""" @@ -225,6 +215,7 @@ class TestLiveFileUpload: # Scenario 6: Configuration query — real config loading # =========================================================================== + class TestLiveConfigQueries: def test_list_models_returns_configured_model(self, client): """list_models() returns at least one configured model with Gateway-aligned fields.""" @@ -266,25 +257,25 @@ class TestLiveConfigQueries: # Scenario 7: Artifact read after agent writes # =========================================================================== + class TestLiveArtifact: def test_get_artifact_after_write(self, client): """Agent writes a file → client reads it back via get_artifact().""" import uuid + thread_id = f"live-artifact-{uuid.uuid4().hex[:8]}" # Ask agent to write a file - events = list(client.stream( - "Use write_file to create /mnt/user-data/outputs/artifact_test.json " - "with content: {\"status\": \"ok\", \"source\": \"live_test\"}", - thread_id=thread_id, - )) + events = list( + client.stream( + 'Use write_file to create /mnt/user-data/outputs/artifact_test.json with content: {"status": "ok", "source": "live_test"}', + thread_id=thread_id, + ) + ) # Verify write happened tc_events = [e for e in events if e.type == "messages-tuple" and e.data.get("type") == "ai" and "tool_calls" in e.data] - assert any( - any(tc["name"] == "write_file" for tc in e.data["tool_calls"]) - for e in tc_events - ) + assert any(any(tc["name"] == "write_file" for tc in e.data["tool_calls"]) for e in tc_events) # Read artifact content, mime = client.get_artifact(thread_id, "mnt/user-data/outputs/artifact_test.json") @@ -303,11 +294,13 @@ class TestLiveArtifact: # Scenario 8: Per-call overrides # =========================================================================== + class TestLiveOverrides: def test_thinking_disabled_still_works(self, client): """Explicit thinking_enabled=False override produces a response.""" response = client.chat( - "Say OK.", thinking_enabled=False, + "Say OK.", + thinking_enabled=False, ) assert len(response) > 0 print(f" response: {response}") @@ -317,6 +310,7 @@ class TestLiveOverrides: # Scenario 9: Error resilience # =========================================================================== + class TestLiveErrorResilience: def test_delete_nonexistent_upload(self, client): with pytest.raises(FileNotFoundError): diff --git a/backend/uv.lock b/backend/uv.lock index 9c2412f..a72defc 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -131,6 +131,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -149,6 +158,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -342,6 +370,11 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" }, + { url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" }, + { url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" }, { url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" }, { url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" }, { url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" }, @@ -619,12 +652,14 @@ dependencies = [ { name = "httpx" }, { name = "kubernetes" }, { name = "langchain" }, + { name = "langchain-anthropic" }, { name = "langchain-deepseek" }, { name = "langchain-google-genai" }, { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph" }, { name = "langgraph-api" }, + { name = "langgraph-checkpoint-sqlite" }, { name = "langgraph-cli" }, { name = "langgraph-runtime-inmem" }, { name = "markdownify" }, @@ -656,12 +691,14 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.0" }, { name = "kubernetes", specifier = ">=30.0.0" }, { name = "langchain", specifier = ">=1.2.3" }, + { name = "langchain-anthropic", specifier = ">=1.3.4" }, { name = "langchain-deepseek", specifier = ">=1.0.1" }, { name = "langchain-google-genai", specifier = ">=4.2.1" }, { name = "langchain-mcp-adapters", specifier = ">=0.1.0" }, { name = "langchain-openai", specifier = ">=1.1.7" }, { name = "langgraph", specifier = ">=1.0.6" }, { name = "langgraph-api", specifier = ">=0.7.0,<0.8.0" }, + { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.3" }, { name = "langgraph-cli", specifier = ">=0.4.14" }, { name = "langgraph-runtime-inmem", specifier = ">=0.22.1" }, { name = "markdownify", specifier = ">=1.2.2" }, @@ -700,6 +737,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "dotenv" version = "0.9.9" @@ -1415,9 +1461,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/e5/9b4f58533f8ce3013b1a993289eb11e8607d9c9d9d14699b29c6ac3b4132/langchain-1.2.3-py3-none-any.whl", hash = "sha256:5cdc7c80f672962b030c4b0d16d0d8f26d849c0ada63a4b8653a20d7505512ae", size = 106428, upload-time = "2026-01-08T20:26:29.162Z" }, ] +[[package]] +name = "langchain-anthropic" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4e/7c1ffac126f5e62b0b9066f331f91ae69361e73476fd3ca1b19f8d8a3cc3/langchain_anthropic-1.3.4.tar.gz", hash = "sha256:000ed4c2d6fb8842b4ffeed22a74a3e84f9e9bcb63638e4abbb4a1d8ffa07211", size = 671858, upload-time = "2026-02-24T13:54:01.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/cf/b7c7b7270efbb3db2edbf14b09ba9110a41628f3a85a11cae9527a35641c/langchain_anthropic-1.3.4-py3-none-any.whl", hash = "sha256:cd112dcc8049aef09f58b3c4338b2c9db5ee98105e08664954a4e40d8bf120b9", size = 47454, upload-time = "2026-02-24T13:54:00.53Z" }, +] + [[package]] name = "langchain-core" -version = "1.2.7" +version = "1.2.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -1429,9 +1489,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/93/36226f593df52b871fc24d494c274f3a6b2ac76763a2806e7d35611634a1/langchain_core-1.2.17.tar.gz", hash = "sha256:54aa267f3311e347fb2e50951fe08e53761cebfb999ab80e6748d70525bbe872", size = 836130, upload-time = "2026-03-02T22:47:55.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/073f33ab383a62908eca7ea699586dfea280e77182176e33199c80ddf22a/langchain_core-1.2.17-py3-none-any.whl", hash = "sha256:bf6bd6ce503874e9c2da1669a69383e967c3de1ea808921d19a9a6bff1a9fbbe", size = 502727, upload-time = "2026-03-02T22:47:54.537Z" }, ] [[package]] @@ -1558,6 +1618,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl", hash = "sha256:3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784", size = 46329, upload-time = "2026-01-12T20:30:25.2Z" }, ] +[[package]] +name = "langgraph-checkpoint-sqlite" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "langgraph-checkpoint" }, + { name = "sqlite-vec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/61/40b7f8f29d6de92406e668c35265f409f57064907e31eae84ab3f2a3e3e1/langgraph_checkpoint_sqlite-3.0.3.tar.gz", hash = "sha256:438c234d37dabda979218954c9c6eb1db73bee6492c2f1d3a00552fe23fa34ed", size = 123876, upload-time = "2026-01-19T00:38:44.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/d8/84ef22ee1cc485c4910df450108fd5e246497379522b3c6cfba896f71bf6/langgraph_checkpoint_sqlite-3.0.3-py3-none-any.whl", hash = "sha256:02eb683a79aa6fcda7cd4de43861062a5d160dbbb990ef8a9fd76c979998a952", size = 33593, upload-time = "2026-01-19T00:38:43.288Z" }, +] + [[package]] name = "langgraph-cli" version = "0.4.14" @@ -2110,32 +2184,32 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -2146,48 +2220,48 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.60b1" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] [[package]] @@ -3162,11 +3236,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "82.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] @@ -3219,6 +3293,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/a7/903429719d39ac2c42aa37086c90e816d883560f13c87d51f09a2962e021/speechrecognition-3.14.5-py3-none-any.whl", hash = "sha256:0c496d74e9f29b1daadb0d96f5660f47563e42bf09316dacdd57094c5095977e", size = 32856308, upload-time = "2025-12-31T11:25:41.161Z" }, ] +[[package]] +name = "sqlite-vec" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ed/aabc328f29ee6814033d008ec43e44f2c595447d9cccd5f2aabe60df2933/sqlite_vec-0.1.6-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:77491bcaa6d496f2acb5cc0d0ff0b8964434f141523c121e313f9a7d8088dee3", size = 164075, upload-time = "2024-11-20T16:40:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/a7/57/05604e509a129b22e303758bfa062c19afb020557d5e19b008c64016704e/sqlite_vec-0.1.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fdca35f7ee3243668a055255d4dee4dea7eed5a06da8cad409f89facf4595361", size = 165242, upload-time = "2024-11-20T16:40:31.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/dbb2cc4e5bad88c89c7bb296e2d0a8df58aab9edc75853728c361eefc24f/sqlite_vec-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b0519d9cd96164cd2e08e8eed225197f9cd2f0be82cb04567692a0a4be02da3", size = 103704, upload-time = "2024-11-20T16:40:33.729Z" }, + { url = "https://files.pythonhosted.org/packages/80/76/97f33b1a2446f6ae55e59b33869bed4eafaf59b7f4c662c8d9491b6a714a/sqlite_vec-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:823b0493add80d7fe82ab0fe25df7c0703f4752941aee1c7b2b02cec9656cb24", size = 151556, upload-time = "2024-11-20T16:40:35.387Z" }, + { url = "https://files.pythonhosted.org/packages/6a/98/e8bc58b178266eae2fcf4c9c7a8303a8d41164d781b32d71097924a6bebe/sqlite_vec-0.1.6-py3-none-win_amd64.whl", hash = "sha256:c65bcfd90fa2f41f9000052bcb8bb75d38240b2dae49225389eca6c3136d3f0c", size = 281540, upload-time = "2024-11-20T16:40:37.296Z" }, +] + [[package]] name = "sse-starlette" version = "2.1.3" diff --git a/config.example.yaml b/config.example.yaml index 6c1a47a..559fdb6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -344,3 +344,37 @@ memory: fact_confidence_threshold: 0.7 # Minimum confidence for storing facts injection_enabled: true # Whether to inject memory into system prompt max_injection_tokens: 2000 # Maximum tokens for memory injection + + +# ============================================================================ +# Checkpointer Configuration +# ============================================================================ +# Configure state persistence for the embedded DeerFlowClient. +# The LangGraph Server manages its own state persistence separately +# via the server infrastructure (this setting does not affect it). +# +# When configured, DeerFlowClient will automatically use this checkpointer, +# enabling multi-turn conversations to persist across process restarts. +# +# Supported types: +# memory - In-process only. State is lost when the process exits. (default) +# sqlite - File-based SQLite persistence. Survives restarts. +# Requires: uv add langgraph-checkpoint-sqlite +# postgres - PostgreSQL persistence. Suitable for multi-process deployments. +# Requires: uv add langgraph-checkpoint-postgres psycopg[binary] psycopg-pool +# +# Examples: +# +# In-memory (default when omitted — no persistence): +# checkpointer: +# type: memory +# +# SQLite (file-based, single-process): +# checkpointer: +# type: sqlite +# connection_string: checkpoints.db +# +# PostgreSQL (multi-process, production): +# checkpointer: +# type: postgres +# connection_string: postgresql://user:password@localhost:5432/deerflow