3e6a34297d
Squashes 25 PR commits onto current main. AppConfig becomes a pure value object with no ambient lookup. Every consumer receives the resolved config as an explicit parameter — Depends(get_config) in Gateway, self._app_config in DeerFlowClient, runtime.context.app_config in agent runs, AppConfig.from_file() at the LangGraph Server registration boundary. Phase 1 — frozen data + typed context - All config models (AppConfig, MemoryConfig, DatabaseConfig, …) become frozen=True; no sub-module globals. - AppConfig.from_file() is pure (no side-effect singleton loaders). - Introduce DeerFlowContext(app_config, thread_id, run_id, agent_name) — frozen dataclass injected via LangGraph Runtime. - Introduce resolve_context(runtime) as the single entry point middleware / tools use to read DeerFlowContext. Phase 2 — pure explicit parameter passing - Gateway: app.state.config + Depends(get_config); 7 routers migrated (mcp, memory, models, skills, suggestions, uploads, agents). - DeerFlowClient: __init__(config=...) captures config locally. - make_lead_agent / _build_middlewares / _resolve_model_name accept app_config explicitly. - RunContext.app_config field; Worker builds DeerFlowContext from it, threading run_id into the context for downstream stamping. - Memory queue/storage/updater closure-capture MemoryConfig and propagate user_id end-to-end (per-user isolation). - Sandbox/skills/community/factories/tools thread app_config. - resolve_context() rejects non-typed runtime.context. - Test suite migrated off AppConfig.current() monkey-patches. - AppConfig.current() classmethod deleted. Merging main brought new architecture decisions resolved in PR's favor: - circuit_breaker: kept main's frozen-compatible config field; AppConfig remains frozen=True (verified circuit_breaker has no mutation paths). - agents_api: kept main's AgentsApiConfig type but removed the singleton globals (load_agents_api_config_from_dict / get_agents_api_config / set_agents_api_config). 8 routes in agents.py now read via Depends(get_config). - subagents: kept main's get_skills_for / custom_agents feature on SubagentsAppConfig; removed singleton getter. registry.py now reads app_config.subagents directly. - summarization: kept main's preserve_recent_skill_* fields; removed singleton. - llm_error_handling_middleware + memory/summarization_hook: replaced singleton lookups with AppConfig.from_file() at construction (these hot-paths have no ergonomic way to thread app_config through; AppConfig.from_file is a pure load). - worker.py + thread_data_middleware.py: DeerFlowContext.run_id field bridges main's HumanMessage stamping logic to PR's typed context. Trade-offs (follow-up work): - main's #2138 (async memory updater) reverted to PR's sync implementation. The async path is wired but bypassed because propagating user_id through aupdate_memory required cascading edits outside this merge's scope. - tests/test_subagent_skills_config.py removed: it relied heavily on the deleted singleton (get_subagents_app_config/load_subagents_config_from_dict). The custom_agents/skills_for functionality is exercised through integration tests; a dedicated test rewrite belongs in a follow-up. Verification: backend test suite — 2560 passed, 4 skipped, 84 failures. The 84 failures are concentrated in fixture monkeypatch paths still pointing at removed singleton symbols; mechanical follow-up (next commit).
243 lines
9.5 KiB
Python
243 lines
9.5 KiB
Python
"""Memory storage providers."""
|
|
|
|
import abc
|
|
import json
|
|
import logging
|
|
import threading
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
|
from deerflow.config.memory_config import MemoryConfig
|
|
from deerflow.config.paths import get_paths
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def utc_now_iso_z() -> str:
|
|
"""Current UTC time as ISO-8601 with ``Z`` suffix (matches prior naive-UTC output)."""
|
|
return datetime.now(UTC).isoformat().removesuffix("+00:00") + "Z"
|
|
|
|
|
|
def create_empty_memory() -> dict[str, Any]:
|
|
"""Create an empty memory structure."""
|
|
return {
|
|
"version": "1.0",
|
|
"lastUpdated": utc_now_iso_z(),
|
|
"user": {
|
|
"workContext": {"summary": "", "updatedAt": ""},
|
|
"personalContext": {"summary": "", "updatedAt": ""},
|
|
"topOfMind": {"summary": "", "updatedAt": ""},
|
|
},
|
|
"history": {
|
|
"recentMonths": {"summary": "", "updatedAt": ""},
|
|
"earlierContext": {"summary": "", "updatedAt": ""},
|
|
"longTermBackground": {"summary": "", "updatedAt": ""},
|
|
},
|
|
"facts": [],
|
|
}
|
|
|
|
|
|
class MemoryStorage(abc.ABC):
|
|
"""Abstract base class for memory storage providers."""
|
|
|
|
@abc.abstractmethod
|
|
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
|
"""Load memory data for the given agent."""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
|
"""Force reload memory data for the given agent."""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
|
"""Save memory data for the given agent."""
|
|
pass
|
|
|
|
|
|
class FileMemoryStorage(MemoryStorage):
|
|
"""File-based memory storage provider."""
|
|
|
|
def __init__(self, memory_config: MemoryConfig):
|
|
"""Initialize the file memory storage.
|
|
|
|
Args:
|
|
memory_config: Memory configuration (storage_path etc.). Stored on
|
|
the instance so per-request lookups don't need to reach for
|
|
ambient state.
|
|
"""
|
|
self._memory_config = memory_config
|
|
# Per-user/agent memory cache: keyed by (user_id, agent_name) tuple (None = global)
|
|
# Value: (memory_data, file_mtime)
|
|
self._memory_cache: dict[tuple[str | None, str | None], tuple[dict[str, Any], float | None]] = {}
|
|
# Guards all reads and writes to _memory_cache across concurrent callers.
|
|
self._cache_lock = threading.Lock()
|
|
|
|
def _validate_agent_name(self, agent_name: str) -> None:
|
|
"""Validate that the agent name is safe to use in filesystem paths.
|
|
|
|
Uses the repository's established AGENT_NAME_PATTERN to ensure consistency
|
|
across the codebase and prevent path traversal or other problematic characters.
|
|
"""
|
|
if not agent_name:
|
|
raise ValueError("Agent name must be a non-empty string.")
|
|
if not AGENT_NAME_PATTERN.match(agent_name):
|
|
raise ValueError(f"Invalid agent name {agent_name!r}: names must match {AGENT_NAME_PATTERN.pattern}")
|
|
|
|
def _get_memory_file_path(self, agent_name: str | None = None, *, user_id: str | None = None) -> Path:
|
|
"""Get the path to the memory file."""
|
|
config = self._memory_config
|
|
if user_id is not None:
|
|
if agent_name is not None:
|
|
self._validate_agent_name(agent_name)
|
|
return get_paths().user_agent_memory_file(user_id, agent_name)
|
|
if config.storage_path and Path(config.storage_path).is_absolute():
|
|
return Path(config.storage_path)
|
|
return get_paths().user_memory_file(user_id)
|
|
# Legacy: no user_id
|
|
if agent_name is not None:
|
|
self._validate_agent_name(agent_name)
|
|
return get_paths().agent_memory_file(agent_name)
|
|
if config.storage_path:
|
|
p = Path(config.storage_path)
|
|
return p if p.is_absolute() else get_paths().base_dir / p
|
|
return get_paths().memory_file
|
|
|
|
def _load_memory_from_file(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
|
"""Load memory data from file."""
|
|
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
|
|
|
if not file_path.exists():
|
|
return create_empty_memory()
|
|
|
|
try:
|
|
with open(file_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return data
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning("Failed to load memory file: %s", e)
|
|
return create_empty_memory()
|
|
|
|
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
|
"""Load memory data (cached with file modification time check)."""
|
|
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
|
|
|
try:
|
|
current_mtime = file_path.stat().st_mtime if file_path.exists() else None
|
|
except OSError:
|
|
current_mtime = None
|
|
|
|
cache_key = (user_id, agent_name)
|
|
with self._cache_lock:
|
|
cached = self._memory_cache.get(cache_key)
|
|
if cached is not None and cached[1] == current_mtime:
|
|
return cached[0]
|
|
|
|
memory_data = self._load_memory_from_file(agent_name, user_id=user_id)
|
|
|
|
with self._cache_lock:
|
|
self._memory_cache[cache_key] = (memory_data, current_mtime)
|
|
|
|
return memory_data
|
|
|
|
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
|
"""Reload memory data from file, forcing cache invalidation."""
|
|
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
|
memory_data = self._load_memory_from_file(agent_name, user_id=user_id)
|
|
|
|
try:
|
|
mtime = file_path.stat().st_mtime if file_path.exists() else None
|
|
except OSError:
|
|
mtime = None
|
|
|
|
cache_key = (user_id, agent_name)
|
|
with self._cache_lock:
|
|
self._memory_cache[cache_key] = (memory_data, mtime)
|
|
return memory_data
|
|
|
|
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
|
"""Save memory data to file and update cache."""
|
|
file_path = self._get_memory_file_path(agent_name, user_id=user_id)
|
|
|
|
try:
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Shallow-copy before adding lastUpdated so the caller's dict is not
|
|
# mutated as a side-effect, and the cache reference is not silently
|
|
# updated before the file write succeeds.
|
|
memory_data = {**memory_data, "lastUpdated": utc_now_iso_z()}
|
|
|
|
temp_path = file_path.with_suffix(f".{uuid.uuid4().hex}.tmp")
|
|
with open(temp_path, "w", encoding="utf-8") as f:
|
|
json.dump(memory_data, f, indent=2, ensure_ascii=False)
|
|
|
|
temp_path.replace(file_path)
|
|
|
|
try:
|
|
mtime = file_path.stat().st_mtime
|
|
except OSError:
|
|
mtime = None
|
|
|
|
cache_key = (user_id, agent_name)
|
|
with self._cache_lock:
|
|
self._memory_cache[cache_key] = (memory_data, mtime)
|
|
logger.info("Memory saved to %s", file_path)
|
|
return True
|
|
except OSError as e:
|
|
logger.error("Failed to save memory file: %s", e)
|
|
return False
|
|
|
|
|
|
# Instances keyed by (storage_class_path, id(memory_config)) so tests can
|
|
# construct isolated storages and multi-client setups with different configs
|
|
# don't collide on a single process-wide singleton.
|
|
_storage_instances: dict[tuple[str, int], MemoryStorage] = {}
|
|
_storage_lock = threading.Lock()
|
|
|
|
|
|
def get_memory_storage(memory_config: MemoryConfig) -> MemoryStorage:
|
|
"""Get the configured memory storage instance.
|
|
|
|
Caches one instance per ``(storage_class, memory_config)`` pair. In
|
|
single-config deployments this collapses to one instance; in multi-client
|
|
or test scenarios each config gets its own storage.
|
|
"""
|
|
key = (memory_config.storage_class, id(memory_config))
|
|
existing = _storage_instances.get(key)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
with _storage_lock:
|
|
existing = _storage_instances.get(key)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
storage_class_path = memory_config.storage_class
|
|
try:
|
|
module_path, class_name = storage_class_path.rsplit(".", 1)
|
|
import importlib
|
|
|
|
module = importlib.import_module(module_path)
|
|
storage_class = getattr(module, class_name)
|
|
|
|
# Validate that the configured storage is a MemoryStorage implementation
|
|
if not isinstance(storage_class, type):
|
|
raise TypeError(f"Configured memory storage '{storage_class_path}' is not a class: {storage_class!r}")
|
|
if not issubclass(storage_class, MemoryStorage):
|
|
raise TypeError(f"Configured memory storage '{storage_class_path}' is not a subclass of MemoryStorage")
|
|
|
|
instance = storage_class(memory_config)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to load memory storage %s, falling back to FileMemoryStorage: %s",
|
|
storage_class_path,
|
|
e,
|
|
)
|
|
instance = FileMemoryStorage(memory_config)
|
|
|
|
_storage_instances[key] = instance
|
|
return instance
|