mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-15 09:19:02 +00:00
Re-ports this feature onto the pluggable-memory backend introduced in #4122 (the original #4143 was force-pushed clean by accident and auto-closed). The #4122 refactor moved the staleness logic into the self-contained DeerMem backend (backends/deermem/deermem/core/) and reverted it to the pre-feature global-threshold version, so the per-fact lifetime work is re-applied here against the new module layout + DI (MemoryUpdater is now (config, storage, llm)-injected; config lives on DeerMemConfig, not host MemoryConfig). **expected_valid_days (creation)** The LLM assigns a per-fact review window when storing each new fact. The prompt exposes five tiers (<=14 d transient -> >365 d very stable). The value is capped at write time by staleness_age_days x staleness_max_lifetime_multiplier (default 20.0 -> 1800 d ~= 5 years; range 1.0-100.0) so the model cannot set an initial lifetime so long the fact is never re-evaluated. The default 20.0 makes the "> 365 d very stable" tier achievable out of the box (3.0 silently clamped it to 270 d). **staleFactsToExtend (review)** During staleness review the LLM can emit extension entries for kept facts whose window seems miscalibrated. new_evd = min(days_since_created + extend_by_days, staleness_max_extension_days). Extensions use an absolute ceiling (default 3650 d ~= 10 years; range 90-36500) rather than the creation multiplier - they are deliberate review decisions that must be able to advance the window beyond the initial cap, but the absolute bound prevents timedelta overflow (a model-supplied extend_by_days of 10**9 previously crashed every later candidate-selection pass with OverflowError) and LLM misfire. **Invariant correctness** - Read-time cap removed from _effective_fact_staleness_age; cap is write-time only so extensions actually advance the review window. - proposed_remove_ids hoisted out of the removals sub-block and used to exclude from extension, so a cap-surviving proposed-removal fact is never extended. - extend_by coerced to int before the > 0 guard (a fractional 0.9 would pass the float check then int() to 0, silently writing a zero-delta extension). - days_since uses total_seconds() // 86400 (not .days truncation). - staleness-section html.escape uses quote=False to match the prompt.py convention; only <, >, & break element-text structure. **Tests** test_memory_staleness_review.py was module-level skipped by #4122 ("full unit-test migration is a follow-up"). This PR performs that migration: DI construction via (DeerMemConfig, _FakeStorage), _build_staleness_section back to the (candidates, config) signature, plus new coverage for per-fact selection, EXTEND with the absolute cap, the overflow next-cycle regression, the proposed-removal-not-extendable case, fractional extend_by skipping, and the creation-time cap. 67 tests, all green.
200 lines
8.8 KiB
Python
200 lines
8.8 KiB
Python
"""Configuration for the memory mechanism (host-shared fields only).
|
|
|
|
DeerMem-private fields live in ``backends/deermem/config.py`` (``DeerMemConfig``),
|
|
reached via ``backend_config`` (a dict the factory passes to the backend's
|
|
``__init__``). This module holds ONLY the host-shared fields every backend /
|
|
call site / factory reads: ``enabled`` / ``injection_enabled`` /
|
|
``shutdown_flush_timeout_seconds`` / ``manager_class`` / ``backend_config``.
|
|
Keeping the shared schema slim is what
|
|
makes backends swappable and portable (DeerMem's knobs do not leak onto the
|
|
shared contract).
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Host-shared MemoryConfig fields (read by every backend / call site / factory).
|
|
_SHARED_FIELDS = frozenset({"enabled", "mode", "injection_enabled", "shutdown_flush_timeout_seconds", "manager_class", "backend_config"})
|
|
|
|
# DeerMem-private fields that used to live at the top level of `memory:` in
|
|
# config.yaml (pre-abstraction). On load they are auto-migrated into
|
|
# `backend_config` so an upgrade does NOT silently revert customized settings
|
|
# to defaults. `model_name` maps to `backend_config.model.model` (the new nested
|
|
# model sub-config); the rest are 1:1.
|
|
_LEGACY_DEERMEM_FIELDS = frozenset(
|
|
{
|
|
"storage_path",
|
|
"storage_class",
|
|
"debounce_seconds",
|
|
"max_facts",
|
|
"fact_confidence_threshold",
|
|
"max_injection_tokens",
|
|
"token_counting",
|
|
"guaranteed_categories",
|
|
"guaranteed_token_budget",
|
|
"staleness_review_enabled",
|
|
"staleness_age_days",
|
|
"staleness_min_candidates",
|
|
"staleness_max_removals_per_cycle",
|
|
"staleness_protected_categories",
|
|
"staleness_max_lifetime_multiplier",
|
|
"staleness_max_extension_days",
|
|
"consolidation_enabled",
|
|
"consolidation_min_facts",
|
|
"consolidation_max_groups_per_cycle",
|
|
"consolidation_max_sources",
|
|
"model_name",
|
|
}
|
|
)
|
|
|
|
|
|
class MemoryConfig(BaseModel):
|
|
"""Host-shared memory configuration (backend-agnostic)."""
|
|
|
|
enabled: bool = Field(
|
|
default=True,
|
|
description="Whether to enable the memory mechanism (call-site gate).",
|
|
)
|
|
mode: Literal["middleware", "tool"] = Field(
|
|
default="middleware",
|
|
description=(
|
|
"Memory operation mode. 'middleware': passive LLM summarization after each turn (current behavior). 'tool': model calls memory tools (memory_search, memory_add, etc.) directly. Mutually exclusive — only one mode runs at a time."
|
|
),
|
|
)
|
|
injection_enabled: bool = Field(
|
|
default=True,
|
|
description="Whether to inject memory into the system prompt (call-site gate).",
|
|
)
|
|
shutdown_flush_timeout_seconds: float = Field(
|
|
default=30.0,
|
|
ge=1.0,
|
|
le=300.0,
|
|
description=(
|
|
"Hard time budget (seconds) for draining the memory backend's "
|
|
"pending-update buffer during Gateway graceful shutdown. The drain "
|
|
"makes one LLM call per pending item, so large IM batches may need "
|
|
"a higher value. Must fit inside the pod's K8s "
|
|
"terminationGracePeriodSeconds (together with channel/scheduler "
|
|
"stop) or K8s SIGKILLs the drain mid-flight. The drain runs on a "
|
|
"daemon thread, so on timeout the process proceeds to exit and any "
|
|
"unfinished tail is dropped (same failure direction as no flush, "
|
|
"scoped to the tail). Host-shared (not backend-private): the host "
|
|
"owns the lifespan budget and the K8s grace relationship."
|
|
),
|
|
)
|
|
manager_class: str = Field(
|
|
default="deermem",
|
|
description=(
|
|
"Memory backend selector. Either a registered backend name "
|
|
"(matching a `backends/<name>/` folder that exposes `MANAGER_CLASS`, "
|
|
"e.g. `deermem` / `noop`) or a dotted import path to a "
|
|
"`MemoryManager` subclass. The factory resolves this at "
|
|
"`get_memory_manager()` time and raises `ValueError` on failure "
|
|
"(fail-fast: memory is persistent state, so an unresolved "
|
|
"manager_class is not silently substituted with a different "
|
|
"storage backend)."
|
|
),
|
|
)
|
|
backend_config: dict[str, Any] = Field(
|
|
default_factory=dict,
|
|
description=(
|
|
"Backend-private config (a dict), passed verbatim to the backend's "
|
|
"`__init__(backend_config=...)` by the factory. Each backend "
|
|
"self-interprets it (DeerMem parses it into `DeerMemConfig`). Values "
|
|
"live in the host config file (`config.yaml` `memory.backend_config`); "
|
|
"they do not belong on the shared `MemoryConfig` schema."
|
|
),
|
|
)
|
|
|
|
|
|
def should_use_memory_tools(config: MemoryConfig) -> bool:
|
|
"""Return True when memory should use model-directed tools."""
|
|
return config.enabled and config.mode == "tool"
|
|
|
|
|
|
# Global configuration instance
|
|
_memory_config: MemoryConfig = MemoryConfig()
|
|
|
|
|
|
def get_memory_config() -> MemoryConfig:
|
|
"""Get the current memory configuration."""
|
|
return _memory_config
|
|
|
|
|
|
def set_memory_config(config: MemoryConfig) -> None:
|
|
"""Set the memory configuration."""
|
|
global _memory_config
|
|
_memory_config = config
|
|
|
|
|
|
def load_memory_config_from_dict(config_dict: dict) -> None:
|
|
"""Load memory configuration from a dictionary.
|
|
|
|
Host-shared fields (``enabled`` / ``mode`` / ``injection_enabled`` /
|
|
``manager_class`` / ``backend_config``) are read directly. DeerMem-private
|
|
fields that used to live at the top level of ``memory:`` in config.yaml
|
|
(pre-abstraction: ``storage_path``, ``max_facts``, ``debounce_seconds``,
|
|
``model_name``, ``token_counting``, ``staleness_*``, ``consolidation_*``,
|
|
...) are **auto-migrated into ``backend_config``** with a warning, so an
|
|
upgrade from a pre-abstraction config does NOT silently revert customized
|
|
settings to defaults. Unknown top-level keys (likely typos) are warned and
|
|
ignored.
|
|
"""
|
|
global _memory_config
|
|
config_dict = dict(config_dict or {})
|
|
backend_config = dict(config_dict.get("backend_config") or {})
|
|
migrated: list[str] = []
|
|
for key in list(config_dict.keys()):
|
|
if key in _SHARED_FIELDS:
|
|
continue
|
|
if key in _LEGACY_DEERMEM_FIELDS:
|
|
value = config_dict.pop(key)
|
|
if value is None or value == "":
|
|
continue # default / empty value, no migration needed
|
|
if key == "model_name":
|
|
# old top-level model_name -> backend_config.model.model
|
|
model_cfg = dict(backend_config.get("model") or {})
|
|
if "model" not in model_cfg:
|
|
model_cfg["model"] = value
|
|
backend_config["model"] = model_cfg
|
|
migrated.append(f"{key} -> backend_config.model.model")
|
|
elif key == "storage_path" and str(value).endswith(".json"):
|
|
# Pre-abstraction storage_path was a FILE path (absolute = shared
|
|
# file opting out of per-user; a relative value like the old default
|
|
# "memory.json" was ignored for per-user). DeerMem now treats it as a
|
|
# root DIRECTORY. Carrying a file-style value verbatim would be
|
|
# resolved as a dir and either orphan per-user memory or hit
|
|
# NotADirectoryError on save. Drop it so the factory's zero-config
|
|
# runtime_home kicks in (per-user location unchanged:
|
|
# {base_dir}/users/{uid}/memory.json) and warn the operator.
|
|
logger.warning(
|
|
"Legacy memory.storage_path=%r looks like a file path; DeerMem now "
|
|
"treats storage_path as a root DIRECTORY (per-user memory under "
|
|
"{storage_path}/users/{uid}/memory.json). Dropped -- memory now "
|
|
"lands under the default root (runtime_home). Set "
|
|
"memory.backend_config.storage_path to a directory if you want a "
|
|
"custom location.",
|
|
value,
|
|
)
|
|
elif key not in backend_config:
|
|
# don't override an explicit backend_config value
|
|
backend_config[key] = value
|
|
migrated.append(f"{key} -> backend_config.{key}")
|
|
else:
|
|
logger.warning(
|
|
"Unknown memory config key %r at top level (not a shared field %s nor a known legacy DeerMem field); ignored.",
|
|
key,
|
|
sorted(_SHARED_FIELDS),
|
|
)
|
|
if migrated:
|
|
logger.warning(
|
|
"Migrated legacy top-level memory fields into backend_config; move them under memory.backend_config in config.yaml to silence this: %s",
|
|
", ".join(migrated),
|
|
)
|
|
config_dict["backend_config"] = backend_config
|
|
_memory_config = MemoryConfig(**config_dict)
|