deer-flow/backend/tests/test_memory_staleness_review.py
lllyfff 8145d66a33
feat(memory): memory message processing (#4447)
* feat(memory): signals-based update pipeline + always-on watermark/trivial filter

Refactor the DeerMem memory update pipeline (message_processing -> queue ->
updater) around a signals frozenset seam, replacing the
(filtered, correction_detected, reinforcement_detected) 3-tuple with
(filtered, signals: frozenset[str]) end to end.

message_processing:
- Externalize signal-detection patterns to YAML (message_patterns/*.yaml).
- Extend signals from correction/reinforcement to a 6-class set
  (correction/reinforcement/preference/identity/goal/decision); detect_signals
  returns a frozenset aligned with the fact category enum.
- Pure-acknowledgment turns ("ok"/"好的"/...) are always filtered out before
  enqueue (whole-message fullmatch), saving an extraction LLM call.

queue (core/queue.py):
- In-memory list + debounce timer, with flush_sync (graceful-shutdown drain
  that joins an in-flight worker under a hard timeout) and queue_max_depth
  backpressure (signal-bearing updates always admitted; QueueFull otherwise).
- Same-key updates coalesce with a signal union; per-batch success/fail summary.

updater (core/updater.py):
- head500+tail500 message truncation (replaces the 1000-char head chop).
- Always-on per-thread watermark: feed only messages added since the last
  extraction. The watermark is in-memory and is not advanced on failure, so a
  failed/lost update is re-fed on the next conversation turn.
- [MANUAL] prompt marker for user-authored facts (source.type="manual").
- Post-invoke extraction_callback (host-injected) emitting facts_extracted /
  facts_accepted / rejected_low_confidence; the host default logs metrics and
  flags >60% rejection.

Confidence filtering remains in _apply_updates (the existing
fact_confidence_threshold check); there is no separate write gate.
Consolidation stays opt-in (lossy). The ABC add/add_nowait signature is
unchanged, so the summarization flush hook and host are unaffected.

Tests: add test_message_processing_signals, test_updater_truncation,
test_updater_watermark; update queue/updater/consolidation/staleness/pluggable
tests for the signals seam.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(memory): harden update pipeline per PR review

- Catch QueueFull in DeerMem.add/add_nowait so backpressure degrades to
  'update skipped' instead of propagating into after_agent /
  summarization_hook and breaking the agent run (peer middlewares
  self-guard; MemoryMiddleware was the lone exception). Emergency
  (add_nowait) always admits under backpressure -- its data cannot be
  re-fed next turn.
- Rewrite the watermark from index-based to content/identity-based
  (_message_identity + _feed_after_watermark) so it stays correct when
  summarization removes the conversation front -- an index watermark
  pointed at the wrong message and silently skipped un-extracted tail
  turns. The emergency flush bypasses the watermark (bypass_watermark on
  ConversationContext, threaded through update_memory) and coexists with
  (does not replace) a pending normal update, so a flush cannot drop a
  pending update's un-extracted tail.
- Populate facts_accepted / rejected_low_confidence inside _apply_updates
  at the real confidence-filter site (passed_threshold) instead of
  re-deriving the threshold in _finalize_update -- eliminates metric drift.
- Emit extraction metrics in a finally with an 'attempted' flag so
  exception failures (parse error, apply_changes raise after retry) are
  observable, not only the happy path.
- Re-detect signals on the post-watermark feed for the extraction hint so
  it no longer references turns the LLM cannot see; admission-time signals
  still drive backpressure.
- Move the post-batch reschedule inside the queue lock to close a
  non-atomic self._timer race with a concurrent add.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(memory): address follow-up review nits (LRU, metric name, docstring)

- Bound the in-memory watermark cache with a configurable LRU
  (watermark_max_keys, default 4096, 0=unbounded). A dropped key re-extracts
  one batch on that thread's next turn (the documented restart behavior), so
  eviction is safe and preserves the content-identity watermark's
  front-removal guarantee. Adds _watermark_get/_watermark_set helpers and a
  bounded-LRU regression test.
- Rename the extraction metric facts_accepted -> facts_passed_confidence so
  the name matches what the >60% rejection-rate warning assumes (a
  confidence-gate signal, not a persisted-fact count); drop the stale
  "historical semantics" justification. Brand-new callback, one consumer.
- Fix the stale test_message_processing_signals module docstring: the signals
  seam is already swapped to frozenset, and a stale stage-numbering prefix is
  removed.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:16:36 +08:00

1237 lines
49 KiB
Python

"""Tests for the staleness review feature in the memory updater.
Covers:
- Candidate selection (per-fact expected_valid_days + global fallback, protected categories)
- Trigger conditions (min candidates, enabled flag)
- Prompt section formatting (valid:Nd annotation, html escaping)
- Staleness removal in _apply_updates (safety cap, observability)
- Lifetime extensions (staleFactsToExtend) with staleness_max_extension_days cap
- Creation-time cap (staleness_max_lifetime_multiplier) on new-fact expected_valid_days
- Normalization of staleFactsToRemove / staleFactsToExtend from LLM responses
- Integration with _prepare_update_prompt
The memory module was refactored into a pluggable backend (#4122): staleness
config now lives on ``DeerMemConfig`` and ``MemoryUpdater`` is dependency-injected
``(config, storage, llm)``. These tests construct the updater via DI rather than
patching ``get_memory_config``.
"""
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core.updater import (
MemoryUpdater,
_build_staleness_section,
_effective_fact_staleness_age,
_normalize_memory_update_data,
_parse_fact_datetime,
_read_expected_valid_days,
_safe_add_days,
_select_stale_candidates,
)
# ── Helpers ────────────────────────────────────────────────────────────────
def _memory_config(**overrides: object) -> DeerMemConfig:
config = DeerMemConfig()
for key, value in overrides.items():
setattr(config, key, value)
return config
class _FakeStorage:
"""Minimal in-memory storage stub for DI - load() returns a held dict."""
def __init__(self, memory: dict | None = None) -> None:
self._memory = memory or _make_memory([])
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict:
return self._memory
def save(self, memory_data: dict, agent_name: str | None = None, *, user_id: str | None = None) -> bool:
self._memory = memory_data
return True
def _make_updater(config: DeerMemConfig | None = None, memory: dict | None = None) -> MemoryUpdater:
return MemoryUpdater(config or _memory_config(), _FakeStorage(memory), llm=None)
def _make_fact(
fact_id: str,
content: str = "test content",
category: str = "knowledge",
confidence: float = 0.9,
days_ago: int = 100,
expected_valid_days: int | None = None,
) -> dict:
created = (datetime.now(UTC) - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z")
fact: dict = {
"id": fact_id,
"content": content,
"category": category,
"confidence": confidence,
"createdAt": created,
"source": "thread-test",
}
if expected_valid_days is not None:
fact["expected_valid_days"] = expected_valid_days
return fact
def _make_memory(facts: list[dict] | None = None) -> dict:
return {
"version": "1.0",
"lastUpdated": "",
"user": {
"workContext": {"summary": "", "updatedAt": ""},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""},
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground": {"summary": "", "updatedAt": ""},
},
"facts": facts or [],
}
# ── _parse_fact_datetime ──────────────────────────────────────────────────
class TestParseFactDatetime:
def test_z_suffix(self):
result = _parse_fact_datetime("2025-06-01T12:00:00Z")
assert result is not None
assert result.year == 2025
assert result.month == 6
def test_offset_format(self):
result = _parse_fact_datetime("2025-06-01T12:00:00+00:00")
assert result is not None
assert result.year == 2025
def test_empty_string(self):
assert _parse_fact_datetime("") is None
def test_invalid_format(self):
assert _parse_fact_datetime("not a date") is None
def test_naive_datetime_gets_utc(self):
result = _parse_fact_datetime("2025-06-01T12:00:00")
assert result is not None
assert result.tzinfo is not None
# ── _read_expected_valid_days ─────────────────────────────────────────────
class TestReadExpectedValidDays:
"""Shared validator used by the newFact creation cap, the staleness read
path, and consolidation inheritance - so its type rule is tested once here
rather than reimplemented per call site."""
def test_returns_int_when_present(self):
assert _read_expected_valid_days({"expected_valid_days": 365}) == 365
def test_coerces_float_to_int(self):
assert _read_expected_valid_days({"expected_valid_days": 180.7}) == 180
def test_returns_none_when_absent(self):
assert _read_expected_valid_days({}) is None
def test_ignores_bool(self):
assert _read_expected_valid_days({"expected_valid_days": True}) is None
def test_ignores_zero_and_negative(self):
assert _read_expected_valid_days({"expected_valid_days": 0}) is None
assert _read_expected_valid_days({"expected_valid_days": -5}) is None
def test_fractional_below_one_returns_none(self):
# 0.5 passes a raw > 0 check but truncates to 0 - the int coercion must
# happen BEFORE the positivity guard, else 0 leaks out as a (non-positive)
# lifetime instead of None. Regression for the helper-extraction order bug.
assert _read_expected_valid_days({"expected_valid_days": 0.5}) is None
assert _read_expected_valid_days({"expected_valid_days": 0.9}) is None
def test_ignores_non_numeric(self):
assert _read_expected_valid_days({"expected_valid_days": "365"}) is None
def test_rejects_non_finite_values(self):
# int(nan) raises ValueError and int(inf) raises OverflowError; the helper
# must reject non-finite floats (NaN, +/-inf) before coercion so a single
# malformed field in a hand-edited memory.json cannot abort staleness
# selection or consolidation. Python's JSON decoder accepts these as floats.
for bad in (float("nan"), float("inf"), float("-inf")):
assert _read_expected_valid_days({"expected_valid_days": bad}) is None
# sanity: the helper no longer raises on these
assert _read_expected_valid_days({"expected_valid_days": float("nan")}) is None
def test_accepts_huge_int_without_routing_through_float(self):
# Python's JSON decoder parses an integer literal with no decimal point as
# an arbitrary-precision int (not a float), so a hand-edited memory.json
# can carry 10**400. The helper must not route it through float() (which
# raises OverflowError); it returns the int as-is. Whether that int can
# participate in datetime arithmetic is the caller's concern, handled by
# _safe_add_days - the helper's job is type/positivity validation only.
from datetime import timedelta
assert _read_expected_valid_days({"expected_valid_days": 10**400}) == 10**400
assert _read_expected_valid_days({"expected_valid_days": timedelta.max.days}) == timedelta.max.days
assert _read_expected_valid_days({"expected_valid_days": timedelta.max.days + 1}) == timedelta.max.days + 1
# ── _effective_fact_staleness_age ─────────────────────────────────────────
class TestEffectiveFactStalenessAge:
def test_returns_stored_evd_when_present(self):
fact = _make_fact("f1", days_ago=100, expected_valid_days=365)
config = _memory_config(staleness_age_days=90)
assert _effective_fact_staleness_age(fact, config) == 365
def test_falls_back_to_global_age_when_absent(self):
fact = _make_fact("f1", days_ago=100)
config = _memory_config(staleness_age_days=90)
assert _effective_fact_staleness_age(fact, config) == 90
def test_returns_raw_value_above_creation_cap(self):
# Read-time cap is removed; the stored value is returned directly even
# when it exceeds staleness_age_days * multiplier (creation cap only).
fact = _make_fact("f1", days_ago=100, expected_valid_days=999)
config = _memory_config(staleness_age_days=90, staleness_max_lifetime_multiplier=3.0)
assert _effective_fact_staleness_age(fact, config) == 999
def test_accepts_float_from_hand_edited_memory(self):
fact = _make_fact("f1", days_ago=100)
fact["expected_valid_days"] = 180.7
config = _memory_config(staleness_age_days=90)
assert _effective_fact_staleness_age(fact, config) == 180
def test_ignores_bool(self):
fact = _make_fact("f1", days_ago=100)
fact["expected_valid_days"] = True
config = _memory_config(staleness_age_days=90)
assert _effective_fact_staleness_age(fact, config) == 90
def test_ignores_zero_and_negative(self):
config = _memory_config(staleness_age_days=90)
for bad in (0, -5):
fact = _make_fact("f1", days_ago=100)
fact["expected_valid_days"] = bad
assert _effective_fact_staleness_age(fact, config) == 90
def test_falls_back_for_fractional_below_one(self):
# A hand-edited 0.5 must NOT be treated as an explicit (zero) lifetime
# that makes the fact immediately stale - it falls back to the global
# age like any other invalid value. Guards the coercion-order regression
# where int() ran after the > 0 check.
config = _memory_config(staleness_age_days=90)
for bad in (0.5, 0.9):
fact = _make_fact("f1", days_ago=100)
fact["expected_valid_days"] = bad
assert _effective_fact_staleness_age(fact, config) == 90
def test_falls_back_for_non_finite_values(self):
# NaN / +/-inf in a hand-edited memory.json must fall back to the global
# age instead of raising (int(nan) -> ValueError, int(inf) -> OverflowError).
# Drives the persisted-fact read path the reviewer flagged.
config = _memory_config(staleness_age_days=90)
for bad in (float("nan"), float("inf"), float("-inf")):
fact = _make_fact("f1", days_ago=100)
fact["expected_valid_days"] = bad
assert _effective_fact_staleness_age(fact, config) == 90
def test_returns_huge_int_as_is(self):
# A huge int is returned as-is (not routed through float, not rejected);
# datetime-overflow safety is the caller's job (_safe_add_days). The read
# path itself must not raise on such a stored value.
config = _memory_config(staleness_age_days=90)
for v in (10**400, 10**12, 10**9):
fact = _make_fact("f1", days_ago=100)
fact["expected_valid_days"] = v
assert _effective_fact_staleness_age(fact, config) == v
# ── _safe_add_days ────────────────────────────────────────────────────────
class TestSafeAddDays:
"""Guards datetime arithmetic against huge persisted expected_valid_days.
A stored evd above timedelta.max.days (or that pushes the result past
datetime.min/max) raises OverflowError in timedelta(days=...) or in the
addition. _safe_add_days returns None so callers can fall back instead of
aborting the whole staleness/consolidation cycle.
"""
def test_normal_shift(self):
from datetime import UTC, datetime, timedelta
dt = datetime.now(UTC)
assert _safe_add_days(dt, 365) == dt + timedelta(days=365)
def test_negative_shift(self):
from datetime import UTC, datetime, timedelta
dt = datetime.now(UTC)
assert _safe_add_days(dt, -365) == dt + timedelta(days=-365)
def test_huge_int_returns_none(self):
from datetime import UTC, datetime
dt = datetime.now(UTC)
for bad in (10**400, 10**12, 10**9):
assert _safe_add_days(dt, bad) is None
def test_overflow_past_datetime_max_returns_none(self):
# timedelta.max.days itself constructs but now + timedelta.max.days
# overflows datetime.max - must return None, not raise.
from datetime import UTC, datetime, timedelta
dt = datetime.now(UTC)
assert _safe_add_days(dt, timedelta.max.days) is None
# ── _select_stale_candidates ──────────────────────────────────────────────
class TestSelectStaleCandidates:
def test_old_facts_selected(self):
memory = _make_memory([_make_fact("fact_old", days_ago=100), _make_fact("fact_new", days_ago=10)])
config = _memory_config(staleness_age_days=90)
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 1
assert candidates[0]["id"] == "fact_old"
def test_protected_category_excluded(self):
memory = _make_memory([_make_fact("fact_old", category="correction", days_ago=100), _make_fact("fact_norm", days_ago=100)])
config = _memory_config(staleness_age_days=90, staleness_protected_categories=["correction"])
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 1
assert candidates[0]["id"] == "fact_norm"
def test_custom_protected_categories(self):
memory = _make_memory([_make_fact("fact_goal", category="goal", days_ago=100), _make_fact("fact_know", days_ago=100)])
config = _memory_config(staleness_age_days=90, staleness_protected_categories=["goal"])
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 1
assert candidates[0]["id"] == "fact_know"
def test_no_facts(self):
memory = _make_memory([])
config = _memory_config(staleness_age_days=90)
assert _select_stale_candidates(memory, config) == []
def test_all_recent(self):
memory = _make_memory([_make_fact("fact_a", days_ago=10), _make_fact("fact_b", days_ago=20)])
config = _memory_config(staleness_age_days=90)
assert _select_stale_candidates(memory, config) == []
def test_fact_within_evd_not_selected(self):
# days_ago=300 but evd=999 -> within its own review window, not stale.
memory = _make_memory([_make_fact("f1", days_ago=300, expected_valid_days=999)])
config = _memory_config(staleness_age_days=90)
assert _select_stale_candidates(memory, config) == []
def test_fact_past_evd_is_selected(self):
memory = _make_memory([_make_fact("f1", days_ago=400, expected_valid_days=365)])
config = _memory_config(staleness_age_days=90)
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 1
assert candidates[0]["id"] == "f1"
def test_huge_evd_does_not_abort_selection(self):
# A huge persisted expected_valid_days (10**400) makes the review window
# unrepresentable in datetime arithmetic. The fact cannot yet be stale
# (its window is astronomically large), so it is skipped - not selected,
# and crucially the selection does not raise.
config = _memory_config(staleness_age_days=90)
for bad in (10**400, 10**12, 10**9):
memory = _make_memory([_make_fact("f1", days_ago=100, expected_valid_days=bad)])
assert _select_stale_candidates(memory, config) == []
# ── Trigger conditions via _select_stale_candidates + config ─────────────
class TestStalenessTriggerConditions:
"""Trigger logic is tested through _prepare_update_prompt, but the candidate
selection that drives it is exercised here directly."""
def test_below_min_candidates(self):
memory = _make_memory([_make_fact("fact_a", days_ago=100), _make_fact("fact_b", days_ago=100)])
config = _memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3)
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 2 # below min_candidates -> caller won't build section
def test_at_min_candidates(self):
memory = _make_memory([_make_fact(f"fact_{i}", days_ago=100) for i in range(3)])
config = _memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3)
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 3
def test_above_min_candidates(self):
memory = _make_memory([_make_fact(f"fact_{i}", days_ago=100) for i in range(5)])
config = _memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3)
candidates = _select_stale_candidates(memory, config)
assert len(candidates) == 5
# ── _build_staleness_section ─────────────────────────────────────────────
class TestBuildStalenessSection:
def test_empty_candidates(self):
assert _build_staleness_section([], _memory_config()) == ""
def test_includes_fact_details(self):
candidates = [_make_fact("fact_1", "User knows Python", "knowledge", 0.9, days_ago=100)]
section = _build_staleness_section(candidates, _memory_config(staleness_age_days=90))
assert "Staleness Review" in section
assert "<stale_facts>" in section
assert "fact_1" in section
assert "User knows Python" in section
assert "valid:90d" in section # global fallback annotation
def test_multiple_facts(self):
candidates = [
_make_fact("fact_1", "A", days_ago=100),
_make_fact("fact_2", "B", days_ago=200),
]
section = _build_staleness_section(candidates, _memory_config(staleness_age_days=90))
assert "fact_1" in section
assert "fact_2" in section
def test_valid_annotation_uses_stored_evd(self):
candidates = [_make_fact("f1", days_ago=100, expected_valid_days=365)]
section = _build_staleness_section(candidates, _memory_config(staleness_age_days=90))
assert "valid:365d" in section
def test_html_special_chars_in_content_are_escaped(self):
"""Fact content with XML special chars is escaped (quote=False: only
<, >, & break element-text structure; " and ' are left untouched,
consistent with the prompt.py convention)."""
candidates = [
_make_fact("fact_x", 'Like <b>bold</b> & "quotes"', "knowledge", 0.9, days_ago=100),
]
section = _build_staleness_section(candidates, _memory_config())
assert "<b>" not in section
assert "&lt;b&gt;" in section
assert "&amp;" in section
assert '"quotes"' in section # " left unescaped in element-text position
assert "&quot;" not in section
def test_closing_tag_in_content_is_escaped(self):
candidates = [
_make_fact("fact_y", "</stale_facts><injected>bad</injected>", "knowledge", 0.8, days_ago=100),
]
section = _build_staleness_section(candidates, _memory_config())
assert "</stale_facts><injected>" not in section
assert "&lt;/stale_facts&gt;" in section
def test_special_chars_in_category_are_escaped(self):
"""Category name XML special chars are escaped; quote=False so only
<, >, & are escaped - " is left untouched in element-text position."""
candidates = [
_make_fact("fact_z", "content", 'pref<"erences>', 0.8, days_ago=100),
]
section = _build_staleness_section(candidates, _memory_config())
assert 'pref<"erences>' not in section
assert 'pref&lt;"erences&gt;' in section # " left unescaped
# ── _apply_updates with staleness removals ─────────────────────────────────
class TestApplyUpdatesStaleness:
def test_stale_facts_removed(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=10))
current_memory = _make_memory(
[
_make_fact("fact_keep", "User knows Python", days_ago=100),
_make_fact("fact_stale", "User uses Vue.js", days_ago=120),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_stale", "reason": "User switched to React"},
],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["id"] == "fact_keep"
def test_stale_candidate_without_id_does_not_raise(self):
"""A legacy / hand-edited fact that lacks an ``id`` must not crash the
staleness apply path."""
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=10))
aged = (datetime.now(UTC) - timedelta(days=120)).isoformat().replace("+00:00", "Z")
idless_fact = {"content": "User uses Vue.js", "category": "knowledge", "confidence": 0.8, "createdAt": aged}
current_memory = _make_memory([_make_fact("fact_keep", days_ago=100), idless_fact])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_keep", "reason": "outdated"},
],
}
result = updater._apply_updates(current_memory, update_data) # must not raise KeyError
contents = {f.get("content") for f in result["facts"]}
assert "User uses Vue.js" in contents
def test_safety_cap_limits_removals(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=2))
current_memory = _make_memory(
[
_make_fact("fact_high", confidence=0.95, days_ago=100),
_make_fact("fact_mid", confidence=0.80, days_ago=100),
_make_fact("fact_low1", confidence=0.70, days_ago=100),
_make_fact("fact_low2", confidence=0.65, days_ago=100),
_make_fact("fact_low3", confidence=0.60, days_ago=100),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_high", "reason": "outdated"},
{"id": "fact_mid", "reason": "outdated"},
{"id": "fact_low1", "reason": "outdated"},
{"id": "fact_low2", "reason": "outdated"},
{"id": "fact_low3", "reason": "outdated"},
],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 3
remaining_ids = {f["id"] for f in result["facts"]}
assert "fact_high" in remaining_ids
assert "fact_mid" in remaining_ids
assert "fact_low1" in remaining_ids
def test_empty_stale_removals_no_effect(self):
updater = _make_updater(_memory_config(max_facts=100))
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
def test_missing_stale_removals_key_no_effect(self):
updater = _make_updater(_memory_config(max_facts=100))
current_memory = _make_memory([_make_fact("fact_a")])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
def test_contradiction_and_staleness_removals_combined(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=10))
current_memory = _make_memory(
[
_make_fact("fact_keep", days_ago=10),
_make_fact("fact_contradicted", days_ago=10),
_make_fact("fact_stale", days_ago=200),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": ["fact_contradicted"],
"staleFactsToRemove": [{"id": "fact_stale", "reason": "old"}],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["id"] == "fact_keep"
def test_protected_category_fact_refused_at_apply(self):
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_review_enabled=True,
staleness_age_days=90,
staleness_min_candidates=1,
staleness_max_removals_per_cycle=10,
staleness_protected_categories=["correction"],
)
)
current_memory = _make_memory(
[
_make_fact("fact_stale", category="knowledge", days_ago=200),
_make_fact("fact_correction", category="correction", days_ago=200),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_stale", "reason": "outdated"},
{"id": "fact_correction", "reason": "LLM slip"},
],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["id"] == "fact_correction"
def test_non_aged_fact_refused_at_apply(self):
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_review_enabled=True,
staleness_age_days=90,
staleness_min_candidates=1,
staleness_max_removals_per_cycle=10,
)
)
current_memory = _make_memory(
[
_make_fact("fact_stale", days_ago=200),
_make_fact("fact_fresh", days_ago=10),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_stale", "reason": "outdated"},
{"id": "fact_fresh", "reason": "LLM slip"},
],
}
result = updater._apply_updates(current_memory, update_data)
# fact_stale removed, fact_fresh kept (not aged)
ids = {f["id"] for f in result["facts"]}
assert ids == {"fact_fresh"}
def test_guardrail_runs_when_staleness_review_disabled(self):
"""The apply-layer staleness guardrail runs unconditionally (independent
of the staleness_review_enabled flag) as defense-in-depth."""
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_review_enabled=False,
staleness_age_days=90,
staleness_min_candidates=1,
staleness_max_removals_per_cycle=10,
)
)
current_memory = _make_memory(
[
_make_fact("fact_stale", days_ago=200),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_stale", "reason": "outdated"}],
}
result = updater._apply_updates(current_memory, update_data)
assert result["facts"] == []
# ── _apply_updates: lifetime extensions (staleFactsToExtend) ──────────────
class TestApplyUpdatesStaleFactsExtend:
def test_extension_updates_expected_valid_days(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90, staleness_max_lifetime_multiplier=10.0))
current_memory = _make_memory([_make_fact("fact_stable", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_stable", "extend_by_days": 365, "reason": "core skill"}],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
fact = result["facts"][0]
assert "expected_valid_days" in fact
# days_since ~= 100; new_evd = days_since + 365. Allow +/-1 to avoid
# flakiness if the test crosses a UTC midnight between _make_fact and
# _apply_updates.
assert abs(fact["expected_valid_days"] - (100 + 365)) <= 1
def test_extension_not_capped_by_multiplier(self):
# Extensions bypass the staleness_max_lifetime_multiplier creation cap.
# A large extend_by_days (9999) with multiplier=3.0 (creation cap=270)
# should NOT be clamped to 270. staleness_max_extension_days is set high
# enough (36500) to let the value through and isolate the multiplier check.
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=3.0,
staleness_max_extension_days=36500,
)
)
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 9999}],
}
result = updater._apply_updates(current_memory, update_data)
fact = result["facts"][0]
expected = 100 + 9999
assert abs(fact.get("expected_valid_days", 0) - expected) <= 1
def test_extension_capped_by_max_extension_days(self):
# staleness_max_extension_days provides an absolute safety ceiling for
# extensions, guarding against timedelta overflow and LLM misfires.
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=20.0,
staleness_max_extension_days=3650,
)
)
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 9999}],
}
result = updater._apply_updates(current_memory, update_data)
fact = result["facts"][0]
# days_since + 9999 >> 3650, so the absolute cap kicks in
assert fact.get("expected_valid_days") == 3650
def test_huge_extend_by_days_does_not_overflow_next_cycle(self):
# Regression: before staleness_max_extension_days, an LLM-supplied
# extend_by_days=10**9 could store a value that later caused
# OverflowError in timedelta(days=...) during the next
# _select_stale_candidates call, permanently breaking memory updates.
cfg = _memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=20.0,
staleness_max_extension_days=3650,
)
updater = _make_updater(cfg)
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 10**9}],
}
result = updater._apply_updates(current_memory, update_data)
# Stored value must be within timedelta-safe range
assert result["facts"][0]["expected_valid_days"] == 3650
# Next-cycle candidate selection must not raise OverflowError
candidates = _select_stale_candidates(result, cfg)
assert isinstance(candidates, list)
def test_removed_fact_cannot_be_extended(self):
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=10.0,
staleness_max_removals_per_cycle=10,
)
)
current_memory = _make_memory(
[
_make_fact("fact_gone", days_ago=150),
_make_fact("fact_kept", days_ago=150),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_gone", "reason": "outdated"}],
"staleFactsToExtend": [
{"id": "fact_gone", "extend_by_days": 180}, # ignored: removed this cycle
{"id": "fact_kept", "extend_by_days": 180},
],
}
result = updater._apply_updates(current_memory, update_data)
ids = {f["id"] for f in result["facts"]}
assert "fact_gone" not in ids
assert "fact_kept" in ids
kept = next(f for f in result["facts"] if f["id"] == "fact_kept")
assert "expected_valid_days" in kept
def test_non_candidate_fact_cannot_be_extended(self):
"""A fresh (non-stale) fact must be rejected by the candidate guardrail."""
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90))
current_memory = _make_memory([_make_fact("fact_fresh", days_ago=5)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_fresh", "extend_by_days": 180}],
}
result = updater._apply_updates(current_memory, update_data)
assert "expected_valid_days" not in result["facts"][0]
def test_empty_extensions_no_effect(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90))
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert "expected_valid_days" not in result["facts"][0]
def test_fractional_extend_by_days_below_one_is_silently_skipped(self):
# extend_by_days=0.9 coerces to int 0, which is not a positive extension
# - must be rejected without writing a zero-delta expected_valid_days.
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90))
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 0.9}],
}
result = updater._apply_updates(current_memory, update_data)
assert "expected_valid_days" not in result["facts"][0]
def test_proposed_removal_not_extendable_even_when_cap_trims(self):
# When the per-cycle removal cap trims actual deletions, proposed-removal
# facts that survive the cap must still be excluded from extensions.
# f_low (confidence=0.6) and f_high (confidence=0.9) are both proposed
# for removal; cap=1 so only f_low is actually removed. f_high must NOT
# be extended even though it wasn't removed this cycle.
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_removals_per_cycle=1,
)
)
current_memory = _make_memory(
[
_make_fact("f_low", days_ago=120, confidence=0.6),
_make_fact("f_high", days_ago=120, confidence=0.9),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "f_low", "reason": "outdated"},
{"id": "f_high", "reason": "outdated"},
],
"staleFactsToExtend": [{"id": "f_high", "extend_by_days": 180}],
}
result = updater._apply_updates(current_memory, update_data)
ids = {f["id"] for f in result["facts"]}
assert "f_low" not in ids # removed (lowest confidence=0.6 after sort; cap=1)
assert "f_high" in ids # survived cap but must NOT be extended
f_high = next(f for f in result["facts"] if f["id"] == "f_high")
assert "expected_valid_days" not in f_high
# ── expected_valid_days on new facts (creation-time cap) ──────────────────
class TestNewFactsExpectedValidDays:
def test_stored_on_new_fact(self):
# evd=180 is within the default cap (90 * 20.0 = 1800), so stored as-is.
updater = _make_updater(_memory_config(max_facts=100, fact_confidence_threshold=0.7))
current_memory = _make_memory([])
update_data = {
"user": {},
"history": {},
"newFacts": [{"content": "User speaks Spanish natively", "category": "knowledge", "confidence": 0.95, "expected_valid_days": 180}],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["expected_valid_days"] == 180
def test_creation_time_cap_applied_to_new_fact(self):
# evd=3650 exceeds the creation-time cap (90 * 3.0 = 270); stored as 270.
updater = _make_updater(
_memory_config(
max_facts=100,
fact_confidence_threshold=0.7,
staleness_age_days=90,
staleness_max_lifetime_multiplier=3.0,
)
)
current_memory = _make_memory([])
update_data = {
"user": {},
"history": {},
"newFacts": [{"content": "User prefers Python", "category": "knowledge", "confidence": 0.9, "expected_valid_days": 3650}],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["expected_valid_days"] == 270 # clamped to 90 * 3
def test_not_stored_when_absent(self):
updater = _make_updater(_memory_config(max_facts=100, fact_confidence_threshold=0.7))
current_memory = _make_memory([])
update_data = {
"user": {},
"history": {},
"newFacts": [{"content": "User uses Python", "category": "knowledge", "confidence": 0.9}],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert "expected_valid_days" not in result["facts"][0]
# ── Normalization of staleFactsToRemove ────────────────────────────────────
class TestNormalizeStaleFactsToRemove:
def test_valid_entries(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_a", "reason": "User moved offices"},
{"id": "fact_b", "reason": "Tech stack changed"},
],
}
result = _normalize_memory_update_data(data)
assert len(result["staleFactsToRemove"]) == 2
assert result["staleFactsToRemove"][0]["id"] == "fact_a"
assert result["staleFactsToRemove"][1]["reason"] == "Tech stack changed"
def test_missing_key(self):
data = {"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"] == []
def test_non_list_ignored(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": "not a list",
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"] == []
def test_non_dict_entries_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": ["just a string", 42, {"id": "fact_ok", "reason": "valid"}],
}
result = _normalize_memory_update_data(data)
assert len(result["staleFactsToRemove"]) == 1
assert result["staleFactsToRemove"][0]["id"] == "fact_ok"
def test_empty_id_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "", "reason": "no id"}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"] == []
def test_non_string_reason_defaulted(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_a", "reason": 123}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"][0]["reason"] == ""
def test_missing_reason_defaulted(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_a"}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"][0]["reason"] == ""
# ── Normalization of staleFactsToExtend ────────────────────────────────────
class TestNormalizeStaleFactsToExtend:
def test_valid_entries(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [
{"id": "fact_a", "extend_by_days": 365, "reason": "core skill"},
],
}
result = _normalize_memory_update_data(data)
assert len(result["staleFactsToExtend"]) == 1
assert result["staleFactsToExtend"][0]["id"] == "fact_a"
assert result["staleFactsToExtend"][0]["extend_by_days"] == 365
def test_float_extend_by_days_coerced_to_int(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 90.7}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"][0]["extend_by_days"] == 90
def test_fractional_below_one_dropped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 0.9}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_zero_and_negative_dropped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [
{"id": "fact_a", "extend_by_days": 0},
{"id": "fact_b", "extend_by_days": -5},
],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_missing_key(self):
data = {"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_non_string_id_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": 123, "extend_by_days": 365}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_missing_extend_by_days_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "reason": "no days"}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
# ── Integration: _prepare_update_prompt ────────────────────────────────────
class TestPrepareUpdatePromptStaleness:
def test_staleness_section_included_when_triggered(self):
old_facts = [_make_fact(f"fact_{i}", days_ago=100) for i in range(5)]
memory = _make_memory(old_facts)
updater = _make_updater(
_memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3),
memory=memory,
)
msg = MagicMock()
msg.type = "human"
msg.content = "Hello, I'm using React now"
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
signals=frozenset(),
)
assert result is not None
_, messages = result
prompt = "\n".join(m.content for m in messages)
assert "Staleness Review" in prompt
assert "<stale_facts>" in prompt
def test_staleness_section_omitted_when_not_triggered(self):
memory = _make_memory([]) # no facts at all
updater = _make_updater(
_memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3),
memory=memory,
)
msg = MagicMock()
msg.type = "human"
msg.content = "Hello"
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
signals=frozenset(),
)
assert result is not None
_, messages = result
prompt = "\n".join(m.content for m in messages)
assert "Staleness Review" not in prompt
assert "<stale_facts>" not in prompt
def test_staleness_section_omitted_when_disabled(self):
old_facts = [_make_fact(f"fact_{i}", days_ago=200) for i in range(10)]
memory = _make_memory(old_facts)
updater = _make_updater(
_memory_config(staleness_review_enabled=False),
memory=memory,
)
msg = MagicMock()
msg.type = "human"
msg.content = "Hello"
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
signals=frozenset(),
)
assert result is not None
_, messages = result
prompt = "\n".join(m.content for m in messages)
assert "Staleness Review" not in prompt
def test_staleness_section_shows_valid_annotation(self):
old_facts = [_make_fact("f1", days_ago=400, expected_valid_days=365) for _ in range(3)]
memory = _make_memory(old_facts)
updater = _make_updater(
_memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3),
memory=memory,
)
msg = MagicMock()
msg.type = "human"
msg.content = "Hello"
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
signals=frozenset(),
)
assert result is not None
_, prompt = result
# After chat-format externalization, prompt is [SystemMessage, HumanMessage];
# the staleness section is injected into the user message.
assert any("valid:365d" in getattr(m, "content", "") for m in prompt)