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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

184 lines
7.7 KiB
Python

"""Tests for the in-memory watermark (skip already-extracted messages)."""
from __future__ import annotations
import json
from typing import Any
from langchain_core.messages import AIMessage, HumanMessage
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core.storage import MemoryStorage
from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater, _message_identity
class _FakeLLM:
"""Returns a canned empty-update response; counts invocations."""
def __init__(self) -> None:
self.invoke_count = 0
self._response = _EmptyResponse()
def invoke(self, prompt: Any, config: Any = None) -> Any:
self.invoke_count += 1
return self._response
class _EmptyResponse:
content = json.dumps(
{
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
"staleFactsToRemove": [],
"staleFactsToExtend": [],
"factsToConsolidate": [],
}
)
usage_metadata: dict[str, int] | None = None
class _FakeStorage(MemoryStorage):
"""Minimal in-memory storage stub (load/save) for the save() path."""
def __init__(self) -> None:
self.memory: dict[str, Any] = {"version": "2.0", "revision": 0, "user": {}, "history": {}, "facts": []}
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
return json.loads(json.dumps(self.memory))
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
return self.load(agent_name, user_id=user_id)
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None, expected_revision: int | None = None) -> bool:
self.memory = json.loads(json.dumps(memory_data))
return True
def _config(**overrides: Any) -> DeerMemConfig:
base: dict[str, Any] = {}
base.update(overrides)
return DeerMemConfig(**base)
def _msgs(*texts: str) -> list[Any]:
out: list[Any] = []
for t in texts:
out.append(HumanMessage(content=t))
out.append(AIMessage(content=f"reply-{t}"))
return out
def test_watermark_skips_already_extracted_messages() -> None:
llm = _FakeLLM()
updater = MemoryUpdater(_config(), _FakeStorage(), llm)
messages = _msgs("first")
updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 1
# Same messages again -> nothing new since the watermark -> skipped, no LLM call.
result = updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
assert result is True
assert llm.invoke_count == 1
def test_watermark_feeds_only_new_messages_on_growth() -> None:
llm = _FakeLLM()
updater = MemoryUpdater(_config(), _FakeStorage(), llm)
messages = _msgs("first")
updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 1
# Append a new turn; only the new turn is fed (watermark = prior length).
messages += _msgs("second")
updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 2
def test_watermark_is_per_thread() -> None:
llm = _FakeLLM()
updater = MemoryUpdater(_config(), _FakeStorage(), llm)
messages = _msgs("first")
# Thread t1 extracts; thread t2 has its own watermark (starts at 0).
updater.update_memory(messages, thread_id="t1", agent_name="a", user_id="u")
updater.update_memory(messages, thread_id="t2", agent_name="a", user_id="u")
assert llm.invoke_count == 2
def test_watermark_resets_when_conversation_shrinks() -> None:
llm = _FakeLLM()
updater = MemoryUpdater(_config(), _FakeStorage(), llm)
long_msgs = _msgs("first", "second", "third")
updater.update_memory(long_msgs, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 1
# A shorter message list (e.g. after summarization) must not get stuck at a
# watermark past the end; it re-extracts from the start.
short_msgs = _msgs("only")
updater.update_memory(short_msgs, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 2
def test_watermark_front_removal_does_not_skip_pending_tail() -> None:
"""Regression: an index watermark skips un-extracted turns when
summarization removes the conversation front. The watermark is
content/identity based, so after a front removal it finds the last-extracted
message at its NEW index and feeds the real pending tail instead of slicing
past the (now shorter) list.
Setup: 6 messages; pre-set the watermark to msg[3] so msgs[0..3] are
"already extracted" and msgs[4..5] are pending. Summarization then removes
the front pair (msgs[0..1]). The surviving 4-message list must still feed
the pending pair (msgs[4..5]) -- an index watermark (=4) would slice [4:]
on a 4-element list and feed nothing, losing them.
"""
llm = _FakeLLM()
updater = MemoryUpdater(_config(), _FakeStorage(), llm)
msgs = _msgs("a", "b", "c") # [H a, A ra, H b, A rb, H c, A rc]
updater._watermarks[("t1", "u", "a")] = _message_identity(msgs[3]) # ...A rb extracted
surviving = msgs[2:] # summarization removed the front pair
updater.update_memory(surviving, thread_id="t1", agent_name="a", user_id="u")
# The pending tail (H c, A rc) was fed -> exactly one extraction.
assert llm.invoke_count == 1
def test_emergency_flush_bypasses_watermark_and_does_not_regress() -> None:
"""Regression: the emergency (summarization) flush path
bypasses the watermark -- it extracts its subset in full and does NOT
advance the conversation watermark (advancing from the subset's own last
message, which is older than the conversation's latest, would regress it
and skip the real tail on the next normal feed)."""
llm = _FakeLLM()
updater = MemoryUpdater(_config(), _FakeStorage(), llm)
msgs = _msgs("a", "b") # [H a, A ra, H b, A rb]
key = ("t1", "u", "a")
updater._watermarks[key] = _message_identity(msgs[1]) # ...A ra extracted; H b, A rb pending
# Emergency flush of the front subset about to be removed.
updater.update_memory(msgs[:2], thread_id="t1", agent_name="a", user_id="u", bypass_watermark=True)
assert llm.invoke_count == 1 # subset extracted in full
# Watermark did not regress to the subset's last message.
assert updater._watermarks[key] == _message_identity(msgs[1])
# A subsequent normal feed of the full conversation still extracts the tail.
updater.update_memory(msgs, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 2
def test_watermark_cache_is_bounded_lru() -> None:
"""The watermark cache is a bounded LRU: over capacity it drops the
least-recently-used key, and a dropped key re-extracts one batch on the
next turn for that thread (no loss)."""
llm = _FakeLLM()
updater = MemoryUpdater(_config(watermark_max_keys=2), _FakeStorage(), llm)
msgs = _msgs("first")
# Three distinct threads fill the cache (cap=2); the least-recently-used
# key (t1) is evicted.
updater.update_memory(msgs, thread_id="t1", agent_name="a", user_id="u")
updater.update_memory(msgs, thread_id="t2", agent_name="a", user_id="u")
updater.update_memory(msgs, thread_id="t3", agent_name="a", user_id="u")
assert len(updater._watermarks) == 2
assert ("t1", "u", "a") not in updater._watermarks # evicted (LRU)
# t1's next turn finds no watermark -> re-extracts one batch (not skipped).
updater.update_memory(msgs, thread_id="t1", agent_name="a", user_id="u")
assert llm.invoke_count == 4 # t1, t2, t3, then t1 re-extract