mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 08:56:13 +00:00
* 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>
184 lines
7.7 KiB
Python
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
|