mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-09 04:20:34 +00:00
* refactor(agent): ground runtime decisions in structured evidence Use producer status and structured errors for tool recovery, preserve memory context without lexical confirmation, and remove predetermined receipt and skill conclusions. Require explicit MCP discovery and authenticated schedule trial actions. Preserve authorization, ownership, isolation, and bounded execution; update documentation and regression coverage. * test(sandbox): expect read_file initialization error callback * fix(agent): address evidence review follow-ups * fix(agent): preserve retry budgets for OpenAI transport errors * fix(agent): expose tool execution status in model requests Project validated execution metadata and exact tool text into request-only JSON so status survives provider serialization. Preserve stored results and receipt hashes, cover SDK and graph boundaries, and migrate replay input hashes without changing recorded responses. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""Tests for the deterministic near-duplicate fact gate (issue #5252).
|
|
|
|
The gate is opt-in via DeerMem-private config (``fact_dedup_enabled``) and
|
|
must never change the default behavior. A proposed NEW fact whose bounded
|
|
token-Jaccard similarity to an existing fact in the same user/agent scope
|
|
AND category reaches ``fact_dedup_similarity_threshold`` merges into that
|
|
fact (existing id/content/createdAt kept, confidence raised to the maximum,
|
|
source refreshed only on a confidence increase) instead of being appended.
|
|
|
|
Test construction mirrors ``tests/test_memory_scope_gate.py``: a real
|
|
``MemoryUpdater`` with an in-memory storage and ``_apply_updates`` driven
|
|
directly, no LLM, no network.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
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, _fact_content_similarity, _fact_content_tokens
|
|
|
|
|
|
def _memory(facts: list[dict[str, object]] | None = None) -> dict[str, object]:
|
|
return {
|
|
"user": {
|
|
"workContext": {"summary": "", "updatedAt": ""},
|
|
"personalContext": {"summary": "", "updatedAt": ""},
|
|
"topOfMind": {"summary": "", "updatedAt": ""},
|
|
},
|
|
"history": {
|
|
"recentMonths": {"summary": "", "updatedAt": ""},
|
|
"earlierContext": {"summary": "", "updatedAt": ""},
|
|
"longTermBackground": {"summary": "", "updatedAt": ""},
|
|
},
|
|
"facts": copy.deepcopy(facts or []),
|
|
}
|
|
|
|
|
|
class _Storage(MemoryStorage):
|
|
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, object]:
|
|
return _memory()
|
|
|
|
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, object]:
|
|
return self.load(agent_name, user_id=user_id)
|
|
|
|
def save(
|
|
self,
|
|
memory_data: dict[str, object],
|
|
agent_name: str | None = None,
|
|
*,
|
|
user_id: str | None = None,
|
|
expected_revision: int | None = None,
|
|
) -> bool:
|
|
return True
|
|
|
|
|
|
def _updater(**config_overrides: object) -> MemoryUpdater:
|
|
config = DeerMemConfig()
|
|
for key, value in config_overrides.items():
|
|
setattr(config, key, value)
|
|
return MemoryUpdater(config, _Storage(), llm=None)
|
|
|
|
|
|
def _fact(content: str, **overrides: object) -> dict[str, object]:
|
|
fact: dict[str, object] = {
|
|
"content": content,
|
|
"category": "preference",
|
|
"confidence": 0.9,
|
|
"scope": "user",
|
|
"durability": "durable",
|
|
"authority": "descriptive",
|
|
}
|
|
fact.update(overrides)
|
|
return fact
|
|
|
|
|
|
def _stored_fact(fact_id: str, content: str, **overrides: object) -> dict[str, object]:
|
|
fact: dict[str, object] = {
|
|
"id": fact_id,
|
|
"content": content,
|
|
"category": "preference",
|
|
"confidence": 0.9,
|
|
"createdAt": "2026-01-01T00:00:00Z",
|
|
"source": "thread-old",
|
|
}
|
|
fact.update(overrides)
|
|
return fact
|
|
|
|
|
|
def _update(new_facts: list[dict[str, object]]) -> dict[str, object]:
|
|
return {"user": {}, "history": {}, "newFacts": new_facts}
|
|
|
|
|
|
class TestFactDedupGate:
|
|
def test_default_config_keeps_near_duplicate_facts(self):
|
|
updater = _updater()
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
|
|
|
|
result = updater._apply_updates(current, _update([_fact("User prefers concise answers in chat, short form")]))
|
|
|
|
assert len(result["facts"]) == 2
|
|
|
|
def test_enabled_merges_paraphrased_fact(self):
|
|
updater = _updater(fact_dedup_enabled=True)
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
|
|
metrics: dict[str, object] = {}
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User prefers concise answers in chat, short form", confidence=0.95)]),
|
|
metrics=metrics,
|
|
)
|
|
|
|
assert len(result["facts"]) == 1
|
|
merged = result["facts"][0]
|
|
assert merged["id"] == "fact_old"
|
|
assert merged["content"] == "User prefers concise answers in chat"
|
|
assert merged["createdAt"] == "2026-01-01T00:00:00Z"
|
|
assert merged["confidence"] == 0.95
|
|
assert merged["source"] == "unknown" # refreshed from the proposed fact's thread
|
|
assert metrics.get("facts_merged_dedup") == 1
|
|
|
|
def test_merge_tolerates_an_unbounded_existing_confidence(self):
|
|
updater = _updater(fact_dedup_enabled=True)
|
|
current = _memory(
|
|
[_stored_fact("fact_old", "User prefers concise answers in chat", confidence=10**400)],
|
|
)
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User prefers concise answers in chat, short form", confidence=0.95)]),
|
|
)
|
|
|
|
assert len(result["facts"]) == 1
|
|
assert result["facts"][0]["confidence"] == 0.95
|
|
|
|
def test_merge_keeps_higher_existing_confidence(self):
|
|
updater = _updater(fact_dedup_enabled=True)
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat", confidence=0.98)])
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User prefers concise answers in chat, short form", confidence=0.8)]),
|
|
)
|
|
|
|
assert len(result["facts"]) == 1
|
|
assert result["facts"][0]["confidence"] == 0.98
|
|
|
|
def test_different_category_does_not_merge(self):
|
|
updater = _updater(fact_dedup_enabled=True)
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User prefers concise answers in chat, short form", category="project")]),
|
|
)
|
|
|
|
assert len(result["facts"]) == 2
|
|
|
|
def test_unrelated_content_below_threshold_does_not_merge(self):
|
|
updater = _updater(fact_dedup_enabled=True)
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User works on database migrations")]),
|
|
)
|
|
|
|
assert len(result["facts"]) == 2
|
|
|
|
def test_threshold_is_respected(self):
|
|
updater = _updater(fact_dedup_enabled=True, fact_dedup_similarity_threshold=0.9)
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User prefers concise answers in chat, short form")]),
|
|
)
|
|
|
|
# 6/8 = 0.75 token-Jaccard is below the configured 0.9 threshold.
|
|
assert len(result["facts"]) == 2
|
|
|
|
def test_exact_duplicate_still_skipped_without_merge_metrics(self):
|
|
updater = _updater(fact_dedup_enabled=True)
|
|
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
|
|
metrics: dict[str, object] = {}
|
|
|
|
result = updater._apply_updates(
|
|
current,
|
|
_update([_fact("User prefers concise answers in chat")]),
|
|
metrics=metrics,
|
|
)
|
|
|
|
assert len(result["facts"]) == 1
|
|
assert "facts_merged_dedup" not in metrics
|
|
|
|
|
|
class TestFactDedupConfig:
|
|
def test_defaults_keep_legacy_behavior(self):
|
|
config = DeerMemConfig()
|
|
assert config.fact_dedup_enabled is False
|
|
assert config.fact_dedup_similarity_threshold == 0.7
|
|
|
|
def test_backend_config_accepts_new_knobs(self):
|
|
config = DeerMemConfig.from_backend_config(
|
|
{
|
|
"fact_dedup_enabled": True,
|
|
"fact_dedup_similarity_threshold": 0.8,
|
|
}
|
|
)
|
|
assert config.fact_dedup_enabled is True
|
|
assert config.fact_dedup_similarity_threshold == 0.8
|
|
|
|
|
|
@pytest.mark.parametrize("paired", [True, False])
|
|
def test_correction_is_not_merged_into_removal_target(paired):
|
|
old = "User prefers email notifications over sms for weekly reports"
|
|
new = "User prefers push notifications over sms for weekly reports"
|
|
current = _memory([_stored_fact("fact_B", old, confidence=0.5)])
|
|
update = _update([_fact(new, confidence=0.95)])
|
|
removal = {"id": "fact_B", "scope": "user", "reason": "Preference changed"}
|
|
if paired:
|
|
removal["replacementFactIndex"] = 0
|
|
update["factsToRemove"] = [removal]
|
|
metrics = {}
|
|
|
|
result = _updater(fact_dedup_enabled=True)._apply_updates(current, update, thread_id="correction", metrics=metrics)
|
|
|
|
assert len(result["facts"]) == 1
|
|
assert result["facts"][0]["content"] == new
|
|
assert result["facts"][0]["id"] != "fact_B"
|
|
assert result["facts"][0]["source"] == "correction"
|
|
assert metrics.get("facts_merged_dedup", 0) == 0
|
|
|
|
|
|
def test_paired_replacement_is_not_merged_into_another_similar_fact():
|
|
other = "User prefers email notifications over sms for weekly reports"
|
|
current = _memory(
|
|
[
|
|
_stored_fact("fact_B", "User previously disabled all notifications"),
|
|
_stored_fact("fact_other", other),
|
|
]
|
|
)
|
|
new = "User prefers push notifications over sms for weekly reports"
|
|
update = _update([_fact(new, confidence=0.95)])
|
|
update["factsToRemove"] = [{"id": "fact_B", "scope": "user", "reason": "Changed", "replacementFactIndex": 0}]
|
|
|
|
result = _updater(fact_dedup_enabled=True)._apply_updates(current, update)
|
|
|
|
assert {fact["content"] for fact in result["facts"]} == {other, new}
|
|
assert all(fact["id"] != "fact_B" for fact in result["facts"])
|
|
|
|
|
|
def test_stale_removal_proposal_is_not_strengthened_when_guard_keeps_it():
|
|
old = _stored_fact("fact_old", "User prefers concise answers in chat")
|
|
update = _update([_fact("User prefers concise answers in chat, short form", confidence=0.95)])
|
|
# Configure preferences as protected from staleness removal. A proposal
|
|
# still must not become evidence strengthening the proposed target.
|
|
update["staleFactsToRemove"] = [{"id": "fact_old", "reason": "Outdated"}]
|
|
|
|
result = _updater(fact_dedup_enabled=True, staleness_protected_categories=["preference"])._apply_updates(_memory([old]), update)
|
|
|
|
assert len(result["facts"]) == 2
|
|
assert next(fact for fact in result["facts"] if fact["id"] == "fact_old") == old
|
|
|
|
|
|
@pytest.mark.parametrize("confidence", [0.8, 0.98])
|
|
def test_nonincreasing_confidence_preserves_source_and_confirmation(confidence):
|
|
old = _stored_fact("fact_old", "User prefers concise answers in chat", confidence=0.98, lastConfirmedAt="2026-01-02T00:00:00Z", confirmationCount=2)
|
|
result = _updater(fact_dedup_enabled=True)._apply_updates(
|
|
_memory([old]),
|
|
_update([_fact("User prefers concise answers in chat, short form", confidence=confidence)]),
|
|
thread_id="restatement",
|
|
)
|
|
assert result["facts"] == [old]
|
|
|
|
|
|
def test_merge_logs_metadata_at_info_without_fact_text(caplog):
|
|
old = "User prefers concise answers in chat"
|
|
new = old + ", short form"
|
|
with caplog.at_level(logging.INFO):
|
|
_updater(fact_dedup_enabled=True)._apply_updates(_memory([_stored_fact("fact_old", old)]), _update([_fact(new, confidence=0.95)]))
|
|
assert "Near-duplicate fact merge proposed" in caplog.text
|
|
assert "fact_old" in caplog.text
|
|
assert "proposal_index=0" in caplog.text
|
|
assert old not in caplog.text
|
|
assert new not in caplog.text
|
|
|
|
|
|
def test_mixed_script_facts_retain_different_cities():
|
|
old = "Prefers Python, 用户在北京"
|
|
new = "Prefers Python, 用户在上海"
|
|
result = _updater(fact_dedup_enabled=True)._apply_updates(_memory([_stored_fact("fact_old", old)]), _update([_fact(new)]))
|
|
assert len(result["facts"]) == 2
|
|
assert _fact_content_similarity(old, new) < 0.7
|
|
|
|
|
|
def test_tokenizer_combines_words_and_cjk_bigrams():
|
|
assert _fact_content_tokens("Prefers Python, 用户在北京") == ["prefers", "python", "用户", "户在", "在北", "北京"]
|
|
assert _fact_content_tokens("用户 北京") == ["用户", "户北", "北京"]
|
|
assert _fact_content_similarity("用户 北京", "北京 用户") < 0.7
|
|
assert _fact_content_tokens("中") == ["中"]
|
|
|
|
|
|
@pytest.mark.parametrize("override", [{"scope": "thread"}, {"confidence": 0.1}])
|
|
def test_rejected_replacement_does_not_delete_or_strengthen_original(override):
|
|
old = _stored_fact("fact_B", "User prefers email notifications over sms for weekly reports", confidence=0.8)
|
|
update = _update([_fact("User prefers push notifications over sms for weekly reports", **override)])
|
|
update["factsToRemove"] = [{"id": "fact_B", "scope": "user", "reason": "Changed", "replacementFactIndex": 0}]
|
|
result = _updater(fact_dedup_enabled=True)._apply_updates(_memory([old]), update)
|
|
assert result["facts"] == [old]
|
|
|
|
|
|
@pytest.mark.parametrize("confirmed", [False, True])
|
|
def test_merge_preserves_deterministic_confirmation_gate(confirmed):
|
|
old = _stored_fact("fact_old", "User prefers concise answers in chat", lastConfirmedAt="2026-01-02T00:00:00Z", confirmationCount=2)
|
|
update = _update([_fact("User prefers concise answers in chat, short form", confidence=0.95)])
|
|
update["factsToReinforce"] = [{"id": "fact_old", "scope": "user", "reason": "Restated"}]
|
|
result = _updater(fact_dedup_enabled=True, fact_eviction_shadow_enabled=True)._apply_updates(
|
|
_memory([old]),
|
|
update,
|
|
signals=frozenset({"reinforcement"}) if confirmed else frozenset(),
|
|
)
|
|
merged = result["facts"][0]
|
|
assert merged["confidence"] == 0.95
|
|
assert merged["confirmationCount"] == 2
|
|
assert merged["lastConfirmedAt"] == old["lastConfirmedAt"]
|