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>
76 lines
2.8 KiB
Python
76 lines
2.8 KiB
Python
"""Tests for user_id propagation through memory queue (DI)."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
|
|
from deerflow.agents.memory.backends.deermem.deermem.core.queue import ConversationContext, MemoryUpdateQueue
|
|
|
|
|
|
def _queue(updater: MagicMock | None = None) -> MemoryUpdateQueue:
|
|
return MemoryUpdateQueue(DeerMemConfig(), updater or MagicMock())
|
|
|
|
|
|
def test_conversation_context_has_user_id():
|
|
ctx = ConversationContext(thread_id="t1", messages=[], user_id="alice")
|
|
assert ctx.user_id == "alice"
|
|
|
|
|
|
def test_conversation_context_user_id_default_none():
|
|
ctx = ConversationContext(thread_id="t1", messages=[])
|
|
assert ctx.user_id is None
|
|
|
|
|
|
def test_queue_add_stores_user_id():
|
|
q = _queue()
|
|
with patch.object(q, "_reset_timer"):
|
|
q.add(thread_id="t1", messages=["msg"], user_id="alice")
|
|
assert len(q._items) == 1
|
|
assert q._items[0].user_id == "alice"
|
|
q.clear()
|
|
|
|
|
|
def test_queue_process_passes_user_id_to_updater():
|
|
mock_updater = MagicMock()
|
|
mock_updater.update_memory.return_value = True
|
|
q = _queue(mock_updater)
|
|
with patch.object(q, "_reset_timer"):
|
|
q.add(thread_id="t1", messages=["msg"], user_id="alice")
|
|
|
|
q._process_queue()
|
|
|
|
mock_updater.update_memory.assert_called_once()
|
|
assert mock_updater.update_memory.call_args.kwargs["user_id"] == "alice"
|
|
|
|
|
|
def test_queue_keeps_updates_for_different_users_in_same_thread_and_agent():
|
|
q = _queue()
|
|
with patch.object(q, "_reset_timer"):
|
|
q.add(thread_id="main", messages=["alice update"], agent_name="researcher", user_id="alice")
|
|
q.add(thread_id="main", messages=["bob update"], agent_name="researcher", user_id="bob")
|
|
|
|
assert q.pending_count == 2
|
|
assert [context.user_id for context in q._items] == ["alice", "bob"]
|
|
assert [context.messages for context in q._items] == [["alice update"], ["bob update"]]
|
|
|
|
|
|
def test_queue_still_coalesces_updates_for_same_user_thread_and_agent():
|
|
q = _queue()
|
|
with patch.object(q, "_reset_timer"):
|
|
q.add(thread_id="main", messages=["first"], agent_name="researcher", user_id="alice")
|
|
q.add(thread_id="main", messages=["second"], agent_name="researcher", user_id="alice")
|
|
|
|
assert q.pending_count == 1
|
|
assert q._items[0].messages == ["second"]
|
|
assert q._items[0].user_id == "alice"
|
|
assert q._items[0].agent_name == "researcher"
|
|
|
|
|
|
def test_add_nowait_keeps_different_users_separate():
|
|
q = _queue()
|
|
with patch.object(q, "_schedule_timer"):
|
|
q.add_nowait(thread_id="main", messages=["alice update"], agent_name="researcher", user_id="alice")
|
|
q.add_nowait(thread_id="main", messages=["bob update"], agent_name="researcher", user_id="bob")
|
|
|
|
assert q.pending_count == 2
|
|
assert [context.user_id for context in q._items] == ["alice", "bob"]
|