"""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 "" 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 bold & "quotes"', "knowledge", 0.9, days_ago=100), ] section = _build_staleness_section(candidates, _memory_config()) assert "" not in section assert "<b>" in section assert "&" in section assert '"quotes"' in section # " left unescaped in element-text position assert """ not in section def test_closing_tag_in_content_is_escaped(self): candidates = [ _make_fact("fact_y", "bad", "knowledge", 0.8, days_ago=100), ] section = _build_staleness_section(candidates, _memory_config()) assert "" not in section assert "</stale_facts>" 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<"erences>' 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 "" 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 "" 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)