mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-13 06:31:47 +00:00
feat(memory): opt-in reserved categories for DeerMem hybrid-v1 eviction (#8194)
* feat(memory): support category capacity reservation in DeerMem hybrid-v1 eviction In long-running personal assistant interactions, core user profile facts (such as health allergies, coding style, or identity constraints) can suffer from capacity starvation as high-confidence conversational context accumulates. This change introduces opt-in category capacity reservation for DeerMem: - In DeerMemConfig and select_facts_for_capacity, support eviction_reserved_categories (defaulting to ['correction'] for historical compatibility) and eviction_decay_exempt_categories (defaulting to empty list). - Wire MemoryUpdater to pass the configured categories to select_facts_for_capacity. - Add deterministic offline unit tests in test_memory_personal_assistant_protection.py verifying root-cause reproduction, bidirectional lifecycle (clean explicit removal and contradiction replacement), and strict backward compatibility under defaults. * fix(memory): preserve correction priority in reserved capacity Co-authored-by: Wistom Zhang <77424093+Yakyou7@users.noreply.github.com> --------- Co-authored-by: Totoro <msy626836554@gmail.com> Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
This commit is contained in:
parent
4d7f770fd6
commit
2ff88fbb29
@ -174,6 +174,14 @@ class DeerMemConfig(BaseModel):
|
||||
eviction_access_half_life_days: int = Field(default=30, ge=1, le=3650)
|
||||
eviction_correction_reserved_fraction: float = Field(default=0.10, ge=0.0, le=1.0)
|
||||
eviction_correction_reserved_max: int = Field(default=10, ge=0, le=100)
|
||||
eviction_reserved_categories: list[str] = Field(
|
||||
default_factory=lambda: ["correction"],
|
||||
description=("Fact categories eligible for reserved capacity slots in hybrid-v1 eviction. Defaults to ['correction'] for historical compatibility."),
|
||||
)
|
||||
eviction_decay_exempt_categories: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=("Fact categories exempt from confirmation freshness time-decay in hybrid-v1 eviction (e.g. permanent preferences and user identity)."),
|
||||
)
|
||||
eviction_audit_max_entries: int = Field(
|
||||
default=200,
|
||||
ge=0,
|
||||
|
||||
@ -75,15 +75,20 @@ def _confirmation_freshness(
|
||||
*,
|
||||
now: datetime,
|
||||
half_life_days: float,
|
||||
decay_exempt: bool = False,
|
||||
) -> float:
|
||||
confirmed_at = _parse_datetime(fact.get("lastConfirmedAt"))
|
||||
if confirmed_at is not None:
|
||||
if decay_exempt:
|
||||
return 1.0
|
||||
elapsed = (now - confirmed_at).total_seconds() / 86400
|
||||
return _decay(elapsed_days=elapsed, half_life_days=half_life_days)
|
||||
|
||||
created_at = _parse_datetime(fact.get("createdAt"))
|
||||
if created_at is None:
|
||||
return 0.0
|
||||
if decay_exempt:
|
||||
return 0.5
|
||||
elapsed = (now - created_at).total_seconds() / 86400
|
||||
# Creation is weaker evidence than an explicit user confirmation.
|
||||
return 0.5 * _decay(elapsed_days=elapsed, half_life_days=half_life_days)
|
||||
@ -123,6 +128,7 @@ def _score_fact(
|
||||
access_weight: float,
|
||||
confirmation_half_life_days: float,
|
||||
access_half_life_days: float,
|
||||
decay_exempt_categories: set[str] | None = None,
|
||||
) -> FactEvictionScore:
|
||||
confidence = _bounded_number(fact.get("confidence"), default=0.5)
|
||||
if policy == EVICTION_POLICY_CONFIDENCE:
|
||||
@ -133,10 +139,16 @@ def _score_fact(
|
||||
if policy != EVICTION_POLICY_HYBRID_V1:
|
||||
raise ValueError(f"Unknown fact eviction policy: {policy!r}")
|
||||
|
||||
decay_exempt = False
|
||||
if decay_exempt_categories:
|
||||
cat = str(fact.get("category") or "").strip().lower()
|
||||
decay_exempt = cat in decay_exempt_categories
|
||||
|
||||
confirmation = _confirmation_freshness(
|
||||
fact,
|
||||
now=now,
|
||||
half_life_days=confirmation_half_life_days,
|
||||
decay_exempt=decay_exempt,
|
||||
)
|
||||
access = _normalized_access_heat(
|
||||
usage,
|
||||
@ -168,15 +180,23 @@ def select_facts_for_capacity(
|
||||
access_half_life_days: float = 30,
|
||||
correction_reserved_fraction: float = 0.10,
|
||||
correction_reserved_max: int = 10,
|
||||
reserved_categories: list[str] | tuple[str, ...] | None = None,
|
||||
decay_exempt_categories: list[str] | tuple[str, ...] | None = None,
|
||||
) -> FactEvictionDecision:
|
||||
"""Select facts under the configured cap without mutating the snapshot.
|
||||
|
||||
``confidence`` exactly preserves the historical ranking. ``hybrid-v1``
|
||||
combines three bounded signals and reserves only the minimum number of
|
||||
correction slots; unused slots immediately return to ordinary competition.
|
||||
capacity slots for configured reserved categories (defaulting to correction);
|
||||
corrections take priority when included. Unused slots return to ordinary competition.
|
||||
"""
|
||||
if isinstance(reserved_categories, str):
|
||||
raise TypeError("reserved_categories must be an iterable of strings, not a bare str")
|
||||
if isinstance(decay_exempt_categories, str):
|
||||
raise TypeError("decay_exempt_categories must be an iterable of strings, not a bare str")
|
||||
evaluated_at = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
usage = usage or {}
|
||||
decay_exempt_set = {c.strip().lower() for c in decay_exempt_categories if isinstance(c, str) and c.strip()} if decay_exempt_categories else None
|
||||
indexed_facts = list(enumerate(facts))
|
||||
scores: dict[str, FactEvictionScore] = {}
|
||||
# Per-position scores: the input list may repeat a fact_id, and the public
|
||||
@ -195,6 +215,7 @@ def select_facts_for_capacity(
|
||||
access_weight=access_weight,
|
||||
confirmation_half_life_days=confirmation_half_life_days,
|
||||
access_half_life_days=access_half_life_days,
|
||||
decay_exempt_categories=decay_exempt_set,
|
||||
)
|
||||
scores[fact_id] = score
|
||||
scored.append(score)
|
||||
@ -211,16 +232,20 @@ def select_facts_for_capacity(
|
||||
indexed_facts,
|
||||
key=lambda item: (-scored[item[0]].value, item[0]),
|
||||
)
|
||||
reserved_count = 0
|
||||
reserved_correction_count = 0
|
||||
selected_indexes: set[int] = set()
|
||||
if policy == EVICTION_POLICY_HYBRID_V1 and max_facts > 0:
|
||||
correction_slots = min(
|
||||
target_categories = {c.strip().lower() for c in reserved_categories if isinstance(c, str) and c.strip()} if reserved_categories is not None else {"correction"}
|
||||
reserved_slots = min(
|
||||
correction_reserved_max,
|
||||
math.ceil(max_facts * correction_reserved_fraction),
|
||||
)
|
||||
corrections = [item for item in ranked if str(item[1].get("category") or "").strip().lower() == "correction"]
|
||||
reserved_count = min(correction_slots, len(corrections), max_facts)
|
||||
selected_indexes.update(index for index, _fact in corrections[:reserved_count])
|
||||
reserved_candidates = [item for item in ranked if str(item[1].get("category") or "").strip().lower() in target_categories]
|
||||
reserved_candidates.sort(key=lambda item: str(item[1].get("category") or "").strip().lower() != "correction")
|
||||
reserved_count = min(reserved_slots, len(reserved_candidates), max_facts)
|
||||
reserved_candidates = reserved_candidates[:reserved_count]
|
||||
selected_indexes.update(index for index, _fact in reserved_candidates)
|
||||
reserved_correction_count = sum(str(fact.get("category") or "").strip().lower() == "correction" for _index, fact in reserved_candidates)
|
||||
|
||||
for index, _fact in ranked:
|
||||
if len(selected_indexes) >= max(0, max_facts):
|
||||
@ -248,5 +273,5 @@ def select_facts_for_capacity(
|
||||
evicted=evicted,
|
||||
scores=scores,
|
||||
policy=policy,
|
||||
reserved_correction_slots=reserved_count,
|
||||
reserved_correction_slots=reserved_correction_count,
|
||||
)
|
||||
|
||||
@ -946,6 +946,8 @@ class MemoryUpdater:
|
||||
"access_half_life_days": self._config.eviction_access_half_life_days,
|
||||
"correction_reserved_fraction": self._config.eviction_correction_reserved_fraction,
|
||||
"correction_reserved_max": self._config.eviction_correction_reserved_max,
|
||||
"reserved_categories": self._config.eviction_reserved_categories,
|
||||
"decay_exempt_categories": self._config.eviction_decay_exempt_categories,
|
||||
}
|
||||
decision = select_facts_for_capacity(
|
||||
facts,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
@ -215,6 +215,72 @@ def test_hybrid_policy_releases_unused_correction_slots() -> None:
|
||||
assert decision.reserved_correction_slots == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("confirmed", [False, True])
|
||||
def test_hybrid_expanded_reserve_preserves_corrections_and_audit(tmp_path, confirmed: bool) -> None:
|
||||
old_timestamp = (NOW - timedelta(days=90)).isoformat()
|
||||
facts = [
|
||||
*[_fact(f"preference_{index}", confidence=0.85, created_at=old_timestamp) for index in range(12)],
|
||||
*[
|
||||
_fact(
|
||||
f"correction_{index}",
|
||||
confidence=0.85,
|
||||
category=category,
|
||||
created_at=old_timestamp,
|
||||
confirmed_at=old_timestamp if confirmed else None,
|
||||
)
|
||||
for index, category in enumerate(("correction", " CORRECTION "))
|
||||
],
|
||||
*[_fact(f"context_{index}", confidence=0.90, category="context", created_at=NOW.isoformat()) for index in range(90)],
|
||||
]
|
||||
decision = select_facts_for_capacity(
|
||||
facts,
|
||||
max_facts=100,
|
||||
policy=EVICTION_POLICY_HYBRID_V1,
|
||||
reserved_categories=["preference", "correction"],
|
||||
decay_exempt_categories=["preference"],
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
assert len(decision.kept) == 100
|
||||
assert {"correction_0", "correction_1"} <= {fact["id"] for fact in decision.kept}
|
||||
assert sum(fact["category"] == "preference" for fact in decision.kept) == 8
|
||||
assert decision.reserved_correction_slots == 2
|
||||
|
||||
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path), retrieval_adapter=""))
|
||||
storage.record_capacity_eviction(decision, max_facts=100, agent_name="default", user_id="u", occurred_at=NOW)
|
||||
memory_path = storage._get_memory_file_path("default", user_id="u")
|
||||
events = json.loads(agent_eviction_audit_path(memory_path, "default").read_text(encoding="utf-8"))
|
||||
assert events[0]["reservedCorrectionSlots"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reserved_categories", [[], ["preference"]])
|
||||
def test_hybrid_reserve_honors_explicit_correction_exclusion(reserved_categories: list[str]) -> None:
|
||||
facts = [
|
||||
*[_fact(f"context_{index}", confidence=0.99, category="context") for index in range(10)],
|
||||
_fact("preference", confidence=0.20),
|
||||
_fact("correction", confidence=0.10, category="correction"),
|
||||
]
|
||||
decision = select_facts_for_capacity(
|
||||
facts,
|
||||
max_facts=10,
|
||||
policy=EVICTION_POLICY_HYBRID_V1,
|
||||
reserved_categories=reserved_categories,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
kept_ids = {fact["id"] for fact in decision.kept}
|
||||
assert len(kept_ids) == 10
|
||||
assert "correction" not in kept_ids
|
||||
assert ("preference" in kept_ids) == bool(reserved_categories)
|
||||
assert decision.reserved_correction_slots == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argument", ["reserved_categories", "decay_exempt_categories"])
|
||||
def test_eviction_rejects_bare_string_category_arguments(argument: str) -> None:
|
||||
with pytest.raises(TypeError, match=argument):
|
||||
select_facts_for_capacity([], max_facts=10, policy=EVICTION_POLICY_HYBRID_V1, **{argument: "preference"})
|
||||
|
||||
|
||||
def test_hybrid_policy_handles_malformed_timestamps() -> None:
|
||||
fact = _fact("malformed", confidence=0.8, created_at="not-a-date", confirmed_at="also-not-a-date")
|
||||
|
||||
|
||||
330
backend/tests/test_memory_personal_assistant_protection.py
Normal file
330
backend/tests/test_memory_personal_assistant_protection.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""Deterministic unit tests for DeerMem Personal Assistant Category Protection in Eviction.
|
||||
|
||||
Addresses Totoro's technical reviews:
|
||||
1. Diagnosis 1 (Root cause isolation):
|
||||
Pinpoints that personal profile loss in multi-turn dialogues occurs exclusively
|
||||
at storage capacity eviction (eviction.py), NOT in retrieval.py.
|
||||
2. Diagnosis 2 (Bidirectional lifecycle verification):
|
||||
- Positive path: Profile facts (preference/identity) survive capacity starvation from chat clutter.
|
||||
- Negative path 1 (Explicit removal): If a user removes a profile fact, it is deleted cleanly
|
||||
and cannot resurrect via capacity reservation.
|
||||
- Negative path 2 (Contradiction replacement): If a user updates/replaces an enduring profile,
|
||||
the old fact is discarded and the new fact takes over without being shadowed by the old.
|
||||
3. Backward compatibility:
|
||||
Default configuration retains historical hybrid-v1 and confidence behavior byte-for-byte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
|
||||
from deerflow.agents.memory.backends.deermem.deermem.core.eviction import (
|
||||
EVICTION_POLICY_CONFIDENCE,
|
||||
EVICTION_POLICY_HYBRID_V1,
|
||||
select_facts_for_capacity,
|
||||
)
|
||||
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import format_memory_for_injection
|
||||
from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage
|
||||
from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater
|
||||
|
||||
NOW = datetime(2026, 10, 15, tzinfo=UTC)
|
||||
|
||||
|
||||
def _fact(
|
||||
fact_id: str,
|
||||
*,
|
||||
content: str | None = None,
|
||||
confidence: float,
|
||||
category: str = "context",
|
||||
created_at: str = "2026-08-01T00:00:00Z",
|
||||
confirmed_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
fact: dict[str, Any] = {
|
||||
"id": fact_id,
|
||||
"content": content or fact_id,
|
||||
"category": category,
|
||||
"confidence": confidence,
|
||||
"createdAt": created_at,
|
||||
}
|
||||
if confirmed_at is not None:
|
||||
fact["lastConfirmedAt"] = confirmed_at
|
||||
return fact
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 1. DIAGNOSIS 1: REPRODUCTION CASE PINPOINTING ROOT CAUSE IN EVICTION
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_reproduction_root_cause_is_eviction_starvation_not_retrieval() -> None:
|
||||
"""Proves that profile amnesia is caused by eviction discarding facts from storage.
|
||||
|
||||
Scenario:
|
||||
- 1 enduring health profile fact (peanut allergy, confidence=0.85, 60 days old)
|
||||
- 10 casual chatter context facts (confidence=0.90, recent)
|
||||
- Storage capacity cap = 10 facts.
|
||||
"""
|
||||
allergy_fact = _fact(
|
||||
"profile_allergy",
|
||||
content="用户对花生及花生油严重过敏(过敏性休克风险,严禁任何花生及其衍生物成分)",
|
||||
confidence=0.85,
|
||||
category="preference",
|
||||
created_at="2026-08-15T00:00:00Z",
|
||||
)
|
||||
context_facts = [_fact(f"context_{i}", content=f"近期普通闲聊工作琐碎信息 #{i}", confidence=0.90, category="context") for i in range(1, 11)]
|
||||
all_facts = [allergy_fact, *context_facts]
|
||||
|
||||
# Baseline (main behavior): Both confidence and hybrid-v1 evict the allergy fact
|
||||
decision_conf = select_facts_for_capacity(all_facts, max_facts=10, policy=EVICTION_POLICY_CONFIDENCE, now=NOW)
|
||||
kept_conf_ids = {f["id"] for f in decision_conf.kept}
|
||||
assert "profile_allergy" not in kept_conf_ids, "Baseline confidence policy must drop the allergy fact"
|
||||
|
||||
decision_hybrid_default = select_facts_for_capacity(
|
||||
all_facts,
|
||||
max_facts=10,
|
||||
policy=EVICTION_POLICY_HYBRID_V1,
|
||||
now=NOW,
|
||||
)
|
||||
kept_hybrid_ids = {f["id"] for f in decision_hybrid_default.kept}
|
||||
assert "profile_allergy" not in kept_hybrid_ids, "Baseline hybrid-v1 must drop the allergy fact without category quota"
|
||||
|
||||
# Once evicted, injection formatting cannot restore it
|
||||
injected_before = format_memory_for_injection({"facts": decision_hybrid_default.kept}, max_tokens=1000)
|
||||
assert "花生" not in injected_before
|
||||
assert "过敏" not in injected_before
|
||||
|
||||
# Narrow Fix (eviction category reservation only):
|
||||
decision_protected = select_facts_for_capacity(
|
||||
all_facts,
|
||||
max_facts=10,
|
||||
policy=EVICTION_POLICY_HYBRID_V1,
|
||||
correction_reserved_fraction=0.20,
|
||||
correction_reserved_max=5,
|
||||
reserved_categories=["correction", "preference", "identity", "constraint"],
|
||||
decay_exempt_categories=["preference", "identity", "constraint"],
|
||||
now=NOW,
|
||||
)
|
||||
kept_protected_ids = {f["id"] for f in decision_protected.kept}
|
||||
assert "profile_allergy" in kept_protected_ids, "With category reservation, allergy fact survives in storage"
|
||||
|
||||
# And thus naturally enters prompt injection without touching retrieval.py!
|
||||
injected_after = format_memory_for_injection({"facts": decision_protected.kept}, max_tokens=1000)
|
||||
assert "花生及花生油严重过敏" in injected_after
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2. DIAGNOSIS 2: BIDIRECTIONAL LIFECYCLE VERIFICATION (NEGATIVE PATHS)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_bidirectional_lifecycle_explicit_removal_is_permanent(tmp_path: Path) -> None:
|
||||
"""Verifies negative path 1: If a user explicitly removes a profile fact,
|
||||
|
||||
it is deleted cleanly from storage and CANNOT be resurrected by category reservation.
|
||||
"""
|
||||
config = DeerMemConfig(
|
||||
storage_path=str(tmp_path),
|
||||
retrieval_adapter="",
|
||||
max_facts=10,
|
||||
fact_eviction_policy="hybrid-v1",
|
||||
eviction_reserved_categories=["correction", "preference", "identity"],
|
||||
eviction_decay_exempt_categories=["preference", "identity"],
|
||||
)
|
||||
storage = FileMemoryStorage(config)
|
||||
updater = MemoryUpdater(config, storage=storage)
|
||||
|
||||
# Initial state: user has a protected preference fact
|
||||
allergy_fact = _fact(
|
||||
"pref_allergy_001",
|
||||
content="用户对花生严重过敏",
|
||||
confidence=0.85,
|
||||
category="preference",
|
||||
)
|
||||
storage.save({"version": "1.0", "facts": [allergy_fact]}, "default", user_id="u1")
|
||||
|
||||
# Verify fact is stored
|
||||
stored = storage.load("default", user_id="u1").get("facts", [])
|
||||
assert any(f["id"] == "pref_allergy_001" for f in stored)
|
||||
|
||||
# User later explicitly removes it ("I am no longer allergic to peanuts")
|
||||
update_data = {
|
||||
"newFacts": [
|
||||
{
|
||||
"content": "用户近期已完成医学脱敏治疗",
|
||||
"category": "context",
|
||||
"confidence": 0.90,
|
||||
"scope": "user",
|
||||
"durability": "durable",
|
||||
"authority": "descriptive",
|
||||
}
|
||||
],
|
||||
"factsToRemove": [
|
||||
{
|
||||
"id": "pref_allergy_001",
|
||||
"scope": "user",
|
||||
"reason": "User completed desensitization therapy",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
current = storage.load("default", user_id="u1")
|
||||
updated = updater._apply_updates(
|
||||
current,
|
||||
update_data,
|
||||
thread_id="t1",
|
||||
agent_name="default",
|
||||
user_id="u1",
|
||||
)
|
||||
storage.save(updated, "default", user_id="u1")
|
||||
|
||||
# Assert: The removed preference fact is GONE from storage
|
||||
updated_facts = storage.load("default", user_id="u1").get("facts", [])
|
||||
updated_ids = {f["id"] for f in updated_facts}
|
||||
assert "pref_allergy_001" not in updated_ids, "Explicitly removed fact must NOT survive in storage"
|
||||
assert any("脱敏治疗" in f["content"] for f in updated_facts)
|
||||
|
||||
|
||||
def test_bidirectional_lifecycle_contradiction_replacement_supersedes_old(tmp_path: Path) -> None:
|
||||
"""Verifies negative path 2: If a user updates/replaces an enduring profile fact,
|
||||
|
||||
the old fact is discarded and the new fact inherits protection, preventing the old from shadowing the new.
|
||||
"""
|
||||
config = DeerMemConfig(
|
||||
storage_path=str(tmp_path),
|
||||
retrieval_adapter="",
|
||||
max_facts=10,
|
||||
fact_eviction_policy="hybrid-v1",
|
||||
eviction_reserved_categories=["correction", "preference", "identity"],
|
||||
eviction_decay_exempt_categories=["preference", "identity"],
|
||||
)
|
||||
storage = FileMemoryStorage(config)
|
||||
updater = MemoryUpdater(config, storage=storage)
|
||||
|
||||
# Step 1: User initially establishes bash script preference 90 days ago
|
||||
old_pref = _fact(
|
||||
"pref_lang_bash",
|
||||
content="用户偏好所有脚本使用 Bash 编写",
|
||||
confidence=0.85,
|
||||
category="preference",
|
||||
created_at="2026-07-01T00:00:00Z",
|
||||
)
|
||||
storage.save({"version": "1.0", "facts": [old_pref]}, "default", user_id="u1")
|
||||
|
||||
# Step 2: User changes their mind: switches preference to Python 3.12
|
||||
update_data = {
|
||||
"newFacts": [
|
||||
{
|
||||
"content": "用户偏好所有脚本使用 Python 3.12 编写",
|
||||
"category": "preference",
|
||||
"confidence": 0.90,
|
||||
"scope": "user",
|
||||
"durability": "durable",
|
||||
"authority": "descriptive",
|
||||
}
|
||||
],
|
||||
"factsToRemove": [
|
||||
{
|
||||
"id": "pref_lang_bash",
|
||||
"scope": "user",
|
||||
"reason": "User switched preference from Bash to Python 3.12",
|
||||
"replacementFactIndex": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
current = storage.load("default", user_id="u1")
|
||||
updated = updater._apply_updates(
|
||||
current,
|
||||
update_data,
|
||||
thread_id="t1",
|
||||
agent_name="default",
|
||||
user_id="u1",
|
||||
)
|
||||
storage.save(updated, "default", user_id="u1")
|
||||
|
||||
facts_after_step2 = storage.load("default", user_id="u1").get("facts", [])
|
||||
step2_ids = {f["id"] for f in facts_after_step2}
|
||||
assert "pref_lang_bash" not in step2_ids, "Old superseded preference must be removed"
|
||||
assert any("Python 3.12" in f["content"] for f in facts_after_step2), "New preference must be stored"
|
||||
|
||||
# Step 3: Now flood memory with casual context facts to trigger capacity eviction (cap=10)
|
||||
flood_update = {
|
||||
"newFacts": [
|
||||
{
|
||||
"content": f"今日工作闲聊碎碎念 #{i}",
|
||||
"category": "context",
|
||||
"confidence": 0.92,
|
||||
"scope": "user",
|
||||
"durability": "durable",
|
||||
"authority": "descriptive",
|
||||
}
|
||||
for i in range(1, 15)
|
||||
]
|
||||
}
|
||||
current_before_flood = storage.load("default", user_id="u1")
|
||||
flooded = updater._apply_updates(
|
||||
current_before_flood,
|
||||
flood_update,
|
||||
thread_id="t1",
|
||||
agent_name="default",
|
||||
user_id="u1",
|
||||
)
|
||||
storage.save(flooded, "default", user_id="u1")
|
||||
|
||||
final_facts = storage.load("default", user_id="u1").get("facts", [])
|
||||
assert len(final_facts) <= 10, "Total facts must respect max_facts=10"
|
||||
# Old bash fact must still be dead
|
||||
assert not any("Bash" in f["content"] for f in final_facts)
|
||||
# New python fact must survive thanks to category reservation
|
||||
assert any("Python 3.12" in f["content"] for f in final_facts), "New preference must survive eviction via reserved quota"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. BACKWARD COMPATIBILITY
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def test_deermem_config_defaults_maintain_strict_backward_compatibility(tmp_path: Path) -> None:
|
||||
"""Verifies that default config values preserve exact historical behavior."""
|
||||
default_config = DeerMemConfig()
|
||||
assert default_config.eviction_reserved_categories == ["correction"]
|
||||
assert default_config.eviction_decay_exempt_categories == []
|
||||
|
||||
# Under defaults, a non-correction fact is never reserved
|
||||
facts = [_fact(f"c_{i}", confidence=0.95, category="context") for i in range(12)]
|
||||
facts.append(_fact("vital_pref", confidence=0.80, category="preference", created_at="2026-01-01T00:00:00Z"))
|
||||
|
||||
storage = FileMemoryStorage(default_config)
|
||||
updater = MemoryUpdater(default_config, storage=storage)
|
||||
|
||||
updater._config.max_facts = 10
|
||||
kept_capped, _, _ = updater._select_for_capacity(facts, agent_name="default", user_id="u1")
|
||||
kept_ids = {f["id"] for f in kept_capped}
|
||||
assert "vital_pref" not in kept_ids, "Under defaults, preference is not protected from eviction"
|
||||
|
||||
|
||||
def test_decay_exemption_defends_against_malformed_facts_without_timestamps() -> None:
|
||||
"""Verifies that decay_exempt does not grant non-zero freshness to malformed facts lacking valid timestamps."""
|
||||
from deerflow.agents.memory.backends.deermem.deermem.core.eviction import _confirmation_freshness
|
||||
|
||||
# Fact with neither createdAt nor lastConfirmedAt
|
||||
corrupted_fact = {"id": "bad_fact", "category": "preference", "confidence": 0.8}
|
||||
score_normal = _confirmation_freshness(corrupted_fact, now=NOW, half_life_days=90, decay_exempt=False)
|
||||
score_exempt = _confirmation_freshness(corrupted_fact, now=NOW, half_life_days=90, decay_exempt=True)
|
||||
assert score_normal == 0.0
|
||||
assert score_exempt == 0.0, "Corrupted fact must not receive 0.5 freshness just because it is exempt"
|
||||
|
||||
# Fact with invalid date strings
|
||||
invalid_fact = {"id": "bad_date", "category": "preference", "createdAt": "invalid-timestamp"}
|
||||
assert _confirmation_freshness(invalid_fact, now=NOW, half_life_days=90, decay_exempt=True) == 0.0
|
||||
|
||||
# Fact with only createdAt receives 0.5 unpenalized
|
||||
valid_created = {"id": "valid_created", "category": "preference", "createdAt": "2026-08-01T00:00:00Z"}
|
||||
assert _confirmation_freshness(valid_created, now=NOW, half_life_days=90, decay_exempt=True) == 0.5
|
||||
|
||||
# Fact with lastConfirmedAt receives 1.0 unpenalized
|
||||
valid_confirmed = {"id": "valid_confirmed", "category": "preference", "lastConfirmedAt": "2026-08-01T00:00:00Z"}
|
||||
assert _confirmation_freshness(valid_confirmed, now=NOW, half_life_days=90, decay_exempt=True) == 1.0
|
||||
@ -2563,6 +2563,8 @@ memory:
|
||||
eviction_access_half_life_days: 30
|
||||
eviction_correction_reserved_fraction: 0.10
|
||||
eviction_correction_reserved_max: 10
|
||||
eviction_reserved_categories: [correction] # hybrid-v1 only: categories eligible for the reserved slots above (default: ["correction"])
|
||||
eviction_decay_exempt_categories: [] # hybrid-v1 only: categories exempt from confirmation freshness time-decay (default: [])
|
||||
eviction_audit_max_entries: 200 # metadata-only events per user/agent scope; 0 disables
|
||||
max_injection_tokens: 2000 # Maximum tokens for memory injection
|
||||
# Token counting strategy for memory-injection budgeting:
|
||||
|
||||
@ -122,6 +122,8 @@ In the default DeerMem `middleware` mode, automatic extraction now classifies ev
|
||||
|
||||
When a fact scope reaches `max_facts`, DeerMem still uses the historical confidence-only eviction order by default. Operators can opt in to `memory.backend_config.fact_eviction_policy: hybrid-v1`, which combines bounded confidence (65%), explicit-confirmation freshness (25%), and query-driven access heat (10%). Hybrid signal metadata is collected only while hybrid-v1 or shadow mode is enabled. Automatic confirmation from lexical reinforcement signals and model-selected fact IDs is disabled: a phrase elsewhere in a batch cannot establish that the user confirmed a particular fact. Existing confirmation metadata remains readable; extraction and injection never increment it. Custom prompts may retain factsToReinforce for compatibility, but that proposal alone cannot update confirmation counters or reset staleness. Access heat is stored in a separate decaying sidecar and increases only when `memory_search` actually returns the fact, so reads do not rewrite canonical Markdown or its `updatedAt`. Hybrid mode also reserves a bounded minimum of correction slots (10% of the cap, at most 10; unused slots return to normal competition). Capacity deletion remains physical, but a bounded metadata-only audit records fact IDs, categories, policy scores, and reasons without copying fact content. `fact_eviction_shadow_enabled: true` evaluates hybrid-v1 alongside the default policy without changing actual retention. This feature adds no LLM invocation and can be rolled back by selecting `confidence`.
|
||||
|
||||
When `eviction_reserved_categories` includes `correction`, corrections take priority within the shared reserve. Other configured categories use the remaining reserved slots in score order. The audit field `reservedCorrectionSlots` counts the correction facts selected by that reservation.
|
||||
|
||||
When `lastConfirmedAt` is absent, hybrid confirmation freshness and staleness review use `createdAt`. Repeated mentions in conversation do not refresh either timestamp.
|
||||
|
||||
File-backed memory now separates global user context from agent facts. Each user has one `memory.json` containing only the project-independent `user` and `history` summaries; every fact is a canonical Markdown file below `agents/{agent_name}/facts/`. Existing lead-agent middleware, API, Settings, import/export, and embedded-client calls that omit `agent_name` resolve inside DeerMem to the reserved `__default__` bucket. That bucket is outside the valid custom-agent name grammar, so a real custom agent named `lead-agent` has a separate fact repository and deleting a custom agent cannot delete a memory-only directory without `config.yaml`. Public agent identifiers are case-insensitive and canonicalized to lowercase. Runtime/API readers still receive a compatibility `facts` array for the selected/default agent, so the frontend does not read agent facts from `memory.json`; structured Markdown `source` metadata is projected to the historical string field at the MemoryManager boundary. An unscoped Clear All first migrates facts from unread legacy per-agent JSON without adopting its soon-to-be-cleared summaries, then removes shared summaries and facts from every agent bucket while preserving agent configuration files, so a later read cannot resurrect skipped legacy facts; an explicitly agent-scoped clear removes only that agent's facts. On first normal read, old facts embedded in the user JSON are migrated automatically to `__default__`; facts written to the earlier implicit `lead-agent` bucket are also moved when that directory is not a real custom agent. Migration and normal writes notify the configured retrieval adapter only after durable storage locks are released. DeerMem uses a scope-aware SQLite FTS5/BM25 adapter by default, stores only rebuildable derived index data under `.retrieval/` (or under `memory.backend_config.retrieval_index_path` when set; a relative value resolves against `storage_path`), and rebuilds it in the background during Gateway startup or lazily on the first scoped search. A corrupt derived index is recreated automatically. When several Gateway instances share one `storage_path`, point `retrieval_index_path` at instance-local disk: SQLite's WAL mode is unsupported on network filesystems, each instance re-syncs a user's index on its next search after a peer writes, and a declared multi-instance deployment (`deployment.multi_instance: true` / `DEER_FLOW_MULTI_INSTANCE=1`) that leaves the index under the memory root logs a warning at startup. The Helm chart mounts a Pod-local `emptyDir` for it. Set `memory.backend_config.retrieval_adapter` to an empty string to disable it and use the local substring fallback. Chinese tokenization is optional; install the backend `memory-zh` extra (`uv sync --extra memory-zh`) for jieba-assisted sub-phrase search. Journaled writes, a shared user lock, and optimistic user-memory revisions prevent silent lost updates.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user