Files
deer-flow/backend/tests/test_memory_staleness_review.py
Tianye SongandGitHub 8511fa6aa3 fix(memory): consolidated facts inherit expected_valid_days from sources (#4225)
* 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).
2026-07-21 09:36:09 +08:00

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 "&lt;b&gt;" in section
assert "&amp;" in section
assert '"quotes"' in section # " left unescaped in element-text position
assert "&quot;" not in section
def test_closing_tag_in_content_is_escaped(self):
candidates = [
_make_fact("fact_y", "</stale_facts><injected>bad</injected>", "knowledge", 0.8, days_ago=100),
]
section = _build_staleness_section(candidates, _memory_config())
assert "</stale_facts><injected>" not in section
assert "&lt;/stale_facts&gt;" in section
def test_special_chars_in_category_are_escaped(self):
"""Category name XML special chars are escaped; quote=False so only
<, >, & are escaped - " is left untouched in element-text position."""
candidates = [
_make_fact("fact_z", "content", 'pref<"erences>', 0.8, days_ago=100),
]
section = _build_staleness_section(candidates, _memory_config())
assert 'pref<"erences>' not in section
assert 'pref&lt;"erences&gt;' in section # " left unescaped
# ── _apply_updates with staleness removals ─────────────────────────────────
class TestApplyUpdatesStaleness:
def test_stale_facts_removed(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=10))
current_memory = _make_memory(
[
_make_fact("fact_keep", "User knows Python", days_ago=100),
_make_fact("fact_stale", "User uses Vue.js", days_ago=120),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_stale", "reason": "User switched to React"},
],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["id"] == "fact_keep"
def test_stale_candidate_without_id_does_not_raise(self):
"""A legacy / hand-edited fact that lacks an ``id`` must not crash the
staleness apply path."""
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=10))
aged = (datetime.now(UTC) - timedelta(days=120)).isoformat().replace("+00:00", "Z")
idless_fact = {"content": "User uses Vue.js", "category": "knowledge", "confidence": 0.8, "createdAt": aged}
current_memory = _make_memory([_make_fact("fact_keep", days_ago=100), idless_fact])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_keep", "reason": "outdated"},
],
}
result = updater._apply_updates(current_memory, update_data) # must not raise KeyError
contents = {f.get("content") for f in result["facts"]}
assert "User uses Vue.js" in contents
def test_safety_cap_limits_removals(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=2))
current_memory = _make_memory(
[
_make_fact("fact_high", confidence=0.95, days_ago=100),
_make_fact("fact_mid", confidence=0.80, days_ago=100),
_make_fact("fact_low1", confidence=0.70, days_ago=100),
_make_fact("fact_low2", confidence=0.65, days_ago=100),
_make_fact("fact_low3", confidence=0.60, days_ago=100),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_high", "reason": "outdated"},
{"id": "fact_mid", "reason": "outdated"},
{"id": "fact_low1", "reason": "outdated"},
{"id": "fact_low2", "reason": "outdated"},
{"id": "fact_low3", "reason": "outdated"},
],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 3
remaining_ids = {f["id"] for f in result["facts"]}
assert "fact_high" in remaining_ids
assert "fact_mid" in remaining_ids
assert "fact_low1" in remaining_ids
def test_empty_stale_removals_no_effect(self):
updater = _make_updater(_memory_config(max_facts=100))
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
def test_missing_stale_removals_key_no_effect(self):
updater = _make_updater(_memory_config(max_facts=100))
current_memory = _make_memory([_make_fact("fact_a")])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
def test_contradiction_and_staleness_removals_combined(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_max_removals_per_cycle=10))
current_memory = _make_memory(
[
_make_fact("fact_keep", days_ago=10),
_make_fact("fact_contradicted", days_ago=10),
_make_fact("fact_stale", days_ago=200),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": ["fact_contradicted"],
"staleFactsToRemove": [{"id": "fact_stale", "reason": "old"}],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["id"] == "fact_keep"
def test_protected_category_fact_refused_at_apply(self):
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_review_enabled=True,
staleness_age_days=90,
staleness_min_candidates=1,
staleness_max_removals_per_cycle=10,
staleness_protected_categories=["correction"],
)
)
current_memory = _make_memory(
[
_make_fact("fact_stale", category="knowledge", days_ago=200),
_make_fact("fact_correction", category="correction", days_ago=200),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_stale", "reason": "outdated"},
{"id": "fact_correction", "reason": "LLM slip"},
],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["id"] == "fact_correction"
def test_non_aged_fact_refused_at_apply(self):
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_review_enabled=True,
staleness_age_days=90,
staleness_min_candidates=1,
staleness_max_removals_per_cycle=10,
)
)
current_memory = _make_memory(
[
_make_fact("fact_stale", days_ago=200),
_make_fact("fact_fresh", days_ago=10),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_stale", "reason": "outdated"},
{"id": "fact_fresh", "reason": "LLM slip"},
],
}
result = updater._apply_updates(current_memory, update_data)
# fact_stale removed, fact_fresh kept (not aged)
ids = {f["id"] for f in result["facts"]}
assert ids == {"fact_fresh"}
def test_guardrail_runs_when_staleness_review_disabled(self):
"""The apply-layer staleness guardrail runs unconditionally (independent
of the staleness_review_enabled flag) as defense-in-depth."""
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_review_enabled=False,
staleness_age_days=90,
staleness_min_candidates=1,
staleness_max_removals_per_cycle=10,
)
)
current_memory = _make_memory(
[
_make_fact("fact_stale", days_ago=200),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_stale", "reason": "outdated"}],
}
result = updater._apply_updates(current_memory, update_data)
assert result["facts"] == []
# ── _apply_updates: lifetime extensions (staleFactsToExtend) ──────────────
class TestApplyUpdatesStaleFactsExtend:
def test_extension_updates_expected_valid_days(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90, staleness_max_lifetime_multiplier=10.0))
current_memory = _make_memory([_make_fact("fact_stable", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_stable", "extend_by_days": 365, "reason": "core skill"}],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
fact = result["facts"][0]
assert "expected_valid_days" in fact
# days_since ~= 100; new_evd = days_since + 365. Allow +/-1 to avoid
# flakiness if the test crosses a UTC midnight between _make_fact and
# _apply_updates.
assert abs(fact["expected_valid_days"] - (100 + 365)) <= 1
def test_extension_not_capped_by_multiplier(self):
# Extensions bypass the staleness_max_lifetime_multiplier creation cap.
# A large extend_by_days (9999) with multiplier=3.0 (creation cap=270)
# should NOT be clamped to 270. staleness_max_extension_days is set high
# enough (36500) to let the value through and isolate the multiplier check.
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=3.0,
staleness_max_extension_days=36500,
)
)
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 9999}],
}
result = updater._apply_updates(current_memory, update_data)
fact = result["facts"][0]
expected = 100 + 9999
assert abs(fact.get("expected_valid_days", 0) - expected) <= 1
def test_extension_capped_by_max_extension_days(self):
# staleness_max_extension_days provides an absolute safety ceiling for
# extensions, guarding against timedelta overflow and LLM misfires.
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=20.0,
staleness_max_extension_days=3650,
)
)
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 9999}],
}
result = updater._apply_updates(current_memory, update_data)
fact = result["facts"][0]
# days_since + 9999 >> 3650, so the absolute cap kicks in
assert fact.get("expected_valid_days") == 3650
def test_huge_extend_by_days_does_not_overflow_next_cycle(self):
# Regression: before staleness_max_extension_days, an LLM-supplied
# extend_by_days=10**9 could store a value that later caused
# OverflowError in timedelta(days=...) during the next
# _select_stale_candidates call, permanently breaking memory updates.
cfg = _memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=20.0,
staleness_max_extension_days=3650,
)
updater = _make_updater(cfg)
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 10**9}],
}
result = updater._apply_updates(current_memory, update_data)
# Stored value must be within timedelta-safe range
assert result["facts"][0]["expected_valid_days"] == 3650
# Next-cycle candidate selection must not raise OverflowError
candidates = _select_stale_candidates(result, cfg)
assert isinstance(candidates, list)
def test_removed_fact_cannot_be_extended(self):
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_lifetime_multiplier=10.0,
staleness_max_removals_per_cycle=10,
)
)
current_memory = _make_memory(
[
_make_fact("fact_gone", days_ago=150),
_make_fact("fact_kept", days_ago=150),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_gone", "reason": "outdated"}],
"staleFactsToExtend": [
{"id": "fact_gone", "extend_by_days": 180}, # ignored: removed this cycle
{"id": "fact_kept", "extend_by_days": 180},
],
}
result = updater._apply_updates(current_memory, update_data)
ids = {f["id"] for f in result["facts"]}
assert "fact_gone" not in ids
assert "fact_kept" in ids
kept = next(f for f in result["facts"] if f["id"] == "fact_kept")
assert "expected_valid_days" in kept
def test_non_candidate_fact_cannot_be_extended(self):
"""A fresh (non-stale) fact must be rejected by the candidate guardrail."""
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90))
current_memory = _make_memory([_make_fact("fact_fresh", days_ago=5)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_fresh", "extend_by_days": 180}],
}
result = updater._apply_updates(current_memory, update_data)
assert "expected_valid_days" not in result["facts"][0]
def test_empty_extensions_no_effect(self):
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90))
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert "expected_valid_days" not in result["facts"][0]
def test_fractional_extend_by_days_below_one_is_silently_skipped(self):
# extend_by_days=0.9 coerces to int 0, which is not a positive extension
# - must be rejected without writing a zero-delta expected_valid_days.
updater = _make_updater(_memory_config(max_facts=100, staleness_age_days=90))
current_memory = _make_memory([_make_fact("fact_a", days_ago=100)])
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 0.9}],
}
result = updater._apply_updates(current_memory, update_data)
assert "expected_valid_days" not in result["facts"][0]
def test_proposed_removal_not_extendable_even_when_cap_trims(self):
# When the per-cycle removal cap trims actual deletions, proposed-removal
# facts that survive the cap must still be excluded from extensions.
# f_low (confidence=0.6) and f_high (confidence=0.9) are both proposed
# for removal; cap=1 so only f_low is actually removed. f_high must NOT
# be extended even though it wasn't removed this cycle.
updater = _make_updater(
_memory_config(
max_facts=100,
staleness_age_days=90,
staleness_max_removals_per_cycle=1,
)
)
current_memory = _make_memory(
[
_make_fact("f_low", days_ago=120, confidence=0.6),
_make_fact("f_high", days_ago=120, confidence=0.9),
]
)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "f_low", "reason": "outdated"},
{"id": "f_high", "reason": "outdated"},
],
"staleFactsToExtend": [{"id": "f_high", "extend_by_days": 180}],
}
result = updater._apply_updates(current_memory, update_data)
ids = {f["id"] for f in result["facts"]}
assert "f_low" not in ids # removed (lowest confidence=0.6 after sort; cap=1)
assert "f_high" in ids # survived cap but must NOT be extended
f_high = next(f for f in result["facts"] if f["id"] == "f_high")
assert "expected_valid_days" not in f_high
# ── expected_valid_days on new facts (creation-time cap) ──────────────────
class TestNewFactsExpectedValidDays:
def test_stored_on_new_fact(self):
# evd=180 is within the default cap (90 * 20.0 = 1800), so stored as-is.
updater = _make_updater(_memory_config(max_facts=100, fact_confidence_threshold=0.7))
current_memory = _make_memory([])
update_data = {
"user": {},
"history": {},
"newFacts": [{"content": "User speaks Spanish natively", "category": "knowledge", "confidence": 0.95, "expected_valid_days": 180}],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["expected_valid_days"] == 180
def test_creation_time_cap_applied_to_new_fact(self):
# evd=3650 exceeds the creation-time cap (90 * 3.0 = 270); stored as 270.
updater = _make_updater(
_memory_config(
max_facts=100,
fact_confidence_threshold=0.7,
staleness_age_days=90,
staleness_max_lifetime_multiplier=3.0,
)
)
current_memory = _make_memory([])
update_data = {
"user": {},
"history": {},
"newFacts": [{"content": "User prefers Python", "category": "knowledge", "confidence": 0.9, "expected_valid_days": 3650}],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert result["facts"][0]["expected_valid_days"] == 270 # clamped to 90 * 3
def test_not_stored_when_absent(self):
updater = _make_updater(_memory_config(max_facts=100, fact_confidence_threshold=0.7))
current_memory = _make_memory([])
update_data = {
"user": {},
"history": {},
"newFacts": [{"content": "User uses Python", "category": "knowledge", "confidence": 0.9}],
"factsToRemove": [],
}
result = updater._apply_updates(current_memory, update_data)
assert len(result["facts"]) == 1
assert "expected_valid_days" not in result["facts"][0]
# ── Normalization of staleFactsToRemove ────────────────────────────────────
class TestNormalizeStaleFactsToRemove:
def test_valid_entries(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [
{"id": "fact_a", "reason": "User moved offices"},
{"id": "fact_b", "reason": "Tech stack changed"},
],
}
result = _normalize_memory_update_data(data)
assert len(result["staleFactsToRemove"]) == 2
assert result["staleFactsToRemove"][0]["id"] == "fact_a"
assert result["staleFactsToRemove"][1]["reason"] == "Tech stack changed"
def test_missing_key(self):
data = {"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"] == []
def test_non_list_ignored(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": "not a list",
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"] == []
def test_non_dict_entries_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": ["just a string", 42, {"id": "fact_ok", "reason": "valid"}],
}
result = _normalize_memory_update_data(data)
assert len(result["staleFactsToRemove"]) == 1
assert result["staleFactsToRemove"][0]["id"] == "fact_ok"
def test_empty_id_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "", "reason": "no id"}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"] == []
def test_non_string_reason_defaulted(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_a", "reason": 123}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"][0]["reason"] == ""
def test_missing_reason_defaulted(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [{"id": "fact_a"}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToRemove"][0]["reason"] == ""
# ── Normalization of staleFactsToExtend ────────────────────────────────────
class TestNormalizeStaleFactsToExtend:
def test_valid_entries(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [
{"id": "fact_a", "extend_by_days": 365, "reason": "core skill"},
],
}
result = _normalize_memory_update_data(data)
assert len(result["staleFactsToExtend"]) == 1
assert result["staleFactsToExtend"][0]["id"] == "fact_a"
assert result["staleFactsToExtend"][0]["extend_by_days"] == 365
def test_float_extend_by_days_coerced_to_int(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 90.7}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"][0]["extend_by_days"] == 90
def test_fractional_below_one_dropped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "extend_by_days": 0.9}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_zero_and_negative_dropped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [
{"id": "fact_a", "extend_by_days": 0},
{"id": "fact_b", "extend_by_days": -5},
],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_missing_key(self):
data = {"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_non_string_id_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": 123, "extend_by_days": 365}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
def test_missing_extend_by_days_skipped(self):
data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToExtend": [{"id": "fact_a", "reason": "no days"}],
}
result = _normalize_memory_update_data(data)
assert result["staleFactsToExtend"] == []
# ── Integration: _prepare_update_prompt ────────────────────────────────────
class TestPrepareUpdatePromptStaleness:
def test_staleness_section_included_when_triggered(self):
old_facts = [_make_fact(f"fact_{i}", days_ago=100) for i in range(5)]
memory = _make_memory(old_facts)
updater = _make_updater(
_memory_config(staleness_review_enabled=True, staleness_age_days=90, staleness_min_candidates=3),
memory=memory,
)
msg = MagicMock()
msg.type = "human"
msg.content = "Hello, I'm using React now"
result = updater._prepare_update_prompt(
messages=[msg],
agent_name=None,
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)