mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
* fix(memory): consolidated facts inherit expected_valid_days from sources Consolidation (#3996) and per-fact expected_valid_days (#4143) were both authored by the same contributor but never connected: the consolidated new_fact carried the newest source's createdAt but no expected_valid_days, so _effective_fact_staleness_age fell back to the global staleness_age_days. A merge of several 200-day-old stable facts (each evd=3650) would land with no evd, read as a 90-day window, and re-enter the staleness candidate set on the very next cycle - the merge discarded the lifetime signal of the underlying information and contradicted consolidation's premise (these are stable, related facts worth synthesising). Fix: the merged fact inherits expected_valid_days set so it is re-reviewed at the EARLIEST source review deadline (min(createdAt + expected_valid_days) across sources, relative to the merged fact's createdAt = the newest source's). A merge combines details from every source, so a volatile sub-detail (evd=7) must not inherit a stable source's 3650-day window and escape staleness review for years - staleness KEEP/REMOVE is the only path that re-validates a merged fact, so biasing toward the soonest deadline keeps uncertain merges re-checked sooner. A source already past its deadline yields a minimal positive window (review next cycle) rather than the global fallback, which would defer an overdue review. Capped at the creation-time staleness_max_lifetime_multiplier like any new fact. Omitted when no source carries a valid evd (legacy facts fall back to the global age at read time, matching pre-feature behaviour). DRY: extract _read_expected_valid_days(fact) -> int | None, the shared type rule (int/float, reject bool, coerce to int BEFORE the > 0 guard) previously inlined in four places - _normalize_memory_update_fact, _effective_fact_staleness_age, the newFact creation cap, and consolidation inheritance. All four call the single helper. Coercing before the guard matters for values in (0, 1): 0.5 passes a raw > 0 check but truncates to 0, which would violate the helper's "positive int or None" contract; the order now matches the original _normalize_memory_update_fact rule. No prompt/schema change: consolidation's prompt does not surface source evd to the LLM, so asking the model to assign a merged lifetime would be guessing without signal. Source inheritance is deterministic and always available. Tests: consolidation evd cases now use time-stable createdAt (relative to now via a _days_ago helper) covering - earliest-deadline selection, creation-cap clamp, omit when no source evd, volatile source governs the deadline (and re-enters staleness next cycle), overdue source clamps to a minimal window, float coercion. Plus TestReadExpectedValidDays / TestEffectiveFactStalenessAge regression cases for the (0, 1) coercion-order fix. * fix(memory): reject non-finite expected_valid_days before int coercion The shared _read_expected_valid_days helper (introduced when consolidating the evd type rule across four call sites) coerces with int(raw) before the > 0 guard - reversing the original _normalize_memory_update_fact order so that a fractional 0.5 does not leak as 0. But int(raw) raises for non-finite floats: int(nan) raises ValueError and int(inf)/int(-inf) raise OverflowError. Python's JSON decoder accepts NaN / Infinity as floats by default, so a single malformed expected_valid_days in a hand-edited memory.json would abort staleness selection or consolidation instead of falling back to the global lifetime. On main, _effective_fact_staleness_age checked raw > 0 first, so NaN fell back safely (nan > 0 is false) - but inf did NOT (inf > 0 is true, so main also crashed on inf). This helper is now the persisted-fact read path for both staleness and consolidation, so the regression (and the pre-existing inf crash) must be closed here. Fix: require math.isfinite(float(raw)) before the int() coercion, then keep the existing positivity check and fallback. NaN / +/-inf all return None, so callers fall back to the global staleness_age_days. Normal int/float values (including large ones) are unaffected - isfinite is a no-op for them. Tests: - TestReadExpectedValidDays.test_rejects_non_finite_values - NaN, inf, -inf return None (not raise). - TestEffectiveFactStalenessAge.test_falls_back_for_non_finite_values - the persisted-fact read path returns the global age for each, no raise. - test_consolidation_with_non_finite_source_evd_does_not_raise - end-to-end: a NaN-evd source merged with a stable source does not abort consolidation; the NaN source's effective lifetime falls back to the global 90 and its deadline participates in the earliest-deadline computation. * test(memory): hoist _select_stale_candidates import + tidy deadline docstring Two review nits from the latest pass: - `_select_stale_candidates` was imported inline inside three test methods; hoisted to the module-level import block so the dependency is declared once. - `test_consolidated_evd_volatile_source_with_equal_created_at_future_deadline` had an abandoned calculation in its comment ("3 + 7 = 10 ... minus 3 already elapsed = 7? No:") that could mislead future readers into thinking elapsed-since-creation factors into the inherited window. Collapsed to a single clear line stating the window is relative to the merged createdAt, regardless of the source's current age. * fix(memory): reject huge-int expected_valid_days above timedelta.max.days _read_expected_valid_days routed every numeric value through float(raw) for the math.isfinite guard, but Python's JSON decoder parses an integer literal with no decimal point as an arbitrary-precision int (unlike 1e400, which decodes to float inf). So a hand-edited memory.json carrying "expected_valid_days": 10**400 raised OverflowError in float(raw) before math.isfinite was ever called - exactly the malformed-field-aborts-everything scenario the helper's docstring claims to prevent. The earlier non-finite fix only closed the float cases (NaN / +/-inf / 1e400). A huge int below the float limit but above timedelta.max.days (e.g. 10**12) would pass the helper and raise OverflowError downstream in timedelta(days=evd) during staleness selection or consolidation - the same crash fancyboi999 flagged for extend_by_days, just reached via a stored evd. Fix: branch on type so an int never passes through float() (matching the reviewer's suggestion), AND cap the returned int at timedelta.max.days (999999999) so the downstream timedelta(days=evd) call cannot overflow either. The float branch keeps the isfinite + int() coercion. Both branches share the 0 < evd <= timedelta.max.days positivity/range check. Normal values are unaffected - any legitimate expected_valid_days is far below the cap (the config ceiling staleness_max_extension_days tops out at 36500). Tests (all three layers): - TestReadExpectedValidDays.test_rejects_huge_int_above_timedelta_max - 10**400, 10**12, 10**9, timedelta.max.days+1 return None; timedelta.max.days itself is accepted. - TestEffectiveFactStalenessAge.test_falls_back_for_huge_int_above_timedelta_max - the persisted-fact read path returns the global age, no raise. - test_consolidation_with_huge_int_source_evd_does_not_raise[1e400|1e12|1e9] - parametrized end-to-end: a huge-int-evd source merged with a stable source does not abort consolidation; the bad source falls back to the global 90. * fix(memory): guard datetime arithmetic, not just timedelta construction The huge-int fix capped _read_expected_valid_days at timedelta.max.days, but that only proves timedelta(days=evd) can be constructed - adding it to a real fact timestamp still overflows datetime.max. @fancyboi999 reproduced it: a source with expected_valid_days=timedelta.max.days raises "OverflowError: date value out of range" at dt + timedelta(...) in the new consolidation deadline calculation. capping at timedelta.max.days was another patch chasing the next overflow boundary, not a real close. Root cause: the helper was doing datetime-range validation, but the safe bound depends on the datetime the evd is added to, not on the evd alone. So the responsibility moves to the arithmetic site, with try/except as the terminal guard - no concrete upper bound to be wrong about. Changes: - _read_expected_valid_days returns any positive int (huge ints included, not routed through float). Its job is type/positivity validation only. - New _safe_add_days(dt, days) -> datetime | None wraps dt + timedelta(days), returning None on OverflowError/ValueError. This is the terminal guard - there is no further boundary to overflow because try/except catches any datetime-range failure regardless of magnitude. - _select_stale_candidates uses _safe_add_days(now, -effective_age); a None result means the window is unrepresentably large, so the fact cannot yet be stale and is skipped (not selected). - Consolidation computes each source's deadline via _safe_add_days; a source whose deadline overflows falls back to the global staleness_age_days deadline (same treatment as a legacy no-evd source), so one malformed field cannot abort the merge. Normal values are unaffected - any legitimate expected_valid_days is far below the overflow boundary (the config ceiling staleness_max_extension_days tops out at 36500). Tests: - TestSafeAddDays: normal/negative shifts; 10**400/10**12/10**9 return None; timedelta.max.days (the exact reproduced value) returns None, not raises. - TestSelectStaleCandidates.test_huge_evd_does_not_abort_selection: a fact with a huge evd is skipped, not selected, and selection does not raise. - test_consolidation_with_huge_int_source_evd_does_not_raise now parametrized over [1e400, 1e12, 1e9, timedelta.max.days] - the last is the value that constructs a valid timedelta but overflows datetime arithmetic. - Helper/read-path tests updated to assert huge ints are returned as-is (the overflow guard is no longer in the helper).
1241 lines
49 KiB
Python
1241 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 "<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", "</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 "</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,
|
|
correction_detected=False,
|
|
reinforcement_detected=False,
|
|
)
|
|
|
|
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,
|
|
correction_detected=False,
|
|
reinforcement_detected=False,
|
|
)
|
|
|
|
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,
|
|
correction_detected=False,
|
|
reinforcement_detected=False,
|
|
)
|
|
|
|
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,
|
|
correction_detected=False,
|
|
reinforcement_detected=False,
|
|
)
|
|
|
|
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)
|