feat(memory): add opt-in relevance-aware retrieval ranking (#5251)

* feat(memory): add opt-in relevance-aware retrieval ranking

Add a deterministic, network-free lexical relevance strategy for DeerMem
(issue #4495): memory_search ranks every fact in scope by idf-weighted
token overlap combined with confidence, with optional greedy-MMR diversity
against near-duplicate facts; prompt injection ranks facts against the
current-turn query threaded from DynamicContextMiddleware through the new
optional `query` keyword on MemoryManager.get_context/aget_context.

Defaults preserve the legacy confidence-only behavior exactly; no prompt,
storage-format, or vector/embedding-dependency changes.

Refs #4495
Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): bound relevance retrieval and apply review feedback

Bound tokenization and index shared stems, preserve mixed CJK tokens, warm jieba, and align missing confidence with legacy injection. Cache MMR token sets and stop selection at result or injection budgets. Document retrieval-adapter precedence and add regression coverage. Refs #4495.

Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): preserve backend compatibility and normalize relevance

Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): omit absent query hints and share injection IDF

Signed-off-by: pwd11 <fvdsrc@163.com>

* test(memory): retain timeout mock until injection worker exits

Signed-off-by: pwd11 <fvdsrc@163.com>

* docs(agents): drop root guidance compaction

Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): validate token prefixes and preserve upload queries

---------

Signed-off-by: pwd11 <fvdsrc@163.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
wd_pan 2026-09-18 11:32:04 +08:00 committed by GitHub
parent 972020cf85
commit cc27730348
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1387 additions and 48 deletions

View File

@ -1618,6 +1618,20 @@ When a fact scope reaches `max_facts`, DeerMem still uses the historical confide
File-backed memory now separates global user context from agent facts. Each user has one `memory.json` containing only the project-independent `user` and `history` summaries; every fact is a canonical Markdown file below `agents/{agent_name}/facts/`. Existing lead-agent middleware, API, Settings, import/export, and embedded-client calls that omit `agent_name` resolve inside DeerMem to the reserved `__default__` bucket. That bucket is outside the valid custom-agent name grammar, so a real custom agent named `lead-agent` has a separate fact repository and deleting a custom agent cannot delete a memory-only directory without `config.yaml`. Public agent identifiers are case-insensitive and canonicalized to lowercase. Runtime/API readers still receive a compatibility `facts` array for the selected/default agent, so the frontend does not read agent facts from `memory.json`; structured Markdown `source` metadata is projected to the historical string field at the MemoryManager boundary. An unscoped Clear All first migrates facts from unread legacy per-agent JSON without adopting its soon-to-be-cleared summaries, then removes shared summaries and facts from every agent bucket while preserving agent configuration files, so a later read cannot resurrect skipped legacy facts; an explicitly agent-scoped clear removes only that agent's facts. On first normal read, old facts embedded in the user JSON are migrated automatically to `__default__`; facts written to the earlier implicit `lead-agent` bucket are also moved when that directory is not a real custom agent. Migration and normal writes notify the configured retrieval adapter only after durable storage locks are released. DeerMem uses a scope-aware SQLite FTS5/BM25 adapter by default, stores only rebuildable derived index data under `.retrieval/`, and rebuilds it in the background during Gateway startup or lazily on the first scoped search. A corrupt derived index is recreated automatically. Set `memory.backend_config.retrieval_adapter` to an empty string to disable it and use the local substring fallback. Chinese tokenization is optional; install the backend `memory-zh` extra (`uv sync --extra memory-zh`) for jieba-assisted sub-phrase search. Journaled writes, a shared user lock, and optimistic user-memory revisions prevent silent lost updates.
Set `memory.backend_config.retrieval_relevance_enabled: true` to opt into deterministic relevance/confidence ranking and query-aware injection. This **bypasses `retrieval_adapter` for search**, including the default FTS5/BM25 and custom adapters; indexing remains configured. `retrieval_relevance_weight` controls the blend and `retrieval_diversity_weight` enables near-duplicate penalties (default 0). Scoring uses at most the first 4096 characters and 128 tokens per query/fact. Search diversifies only up to `top_k`; injection diversifies guaranteed and regular facts independently until their token budgets are reached. Leave the feature disabled to retain the existing retrieval and injection behavior.
Lexical relevance measures IDF-weighted coverage of distinct query terms, so
repeated partial matches cannot tie a complete match merely by saturating the
score. Prefix matches require one complete token to prefix the other, not just
four shared characters. For uploads, query-aware injection uses the preserved
user request rather than the prepended file descriptions; attachment-only
messages retain query-less injection. Older custom memory backends can keep their existing `get_context`
signature: prompt injection and the inherited async wrapper pass `query` only
when that callable supports the keyword and the hint is not `None`.
Search and automatic injection use the same IDF weighting for the same
user/agent candidate scope. Category-filtered search and injection's separate
guaranteed/regular token budgets can still yield different final selections.
Memory injection follows the configured operation mode. In `middleware` mode, DeerMem injects the user-global summaries and the selected agent's facts. Custom-agent bootstrap conversations use that agent's fact bucket as well, so setup details do not leak into the default agent's memory. In `tool` mode, the automatic `<memory>` block contains only the global `user` and `history` summaries; agent facts are retrieved explicitly through `memory_search`, avoiding duplicate automatic and tool-returned fact context. Setting `memory.injection_enabled: false` still disables the entire block in either mode.
An individual Custom Agent can opt out of memory without changing the global setting. Add `memory_enabled: false` to that agent's `users/{user_id}/agents/{name}/config.yaml`. The agent still receives the current-date reminder, but DeerFlow does not inject recalled memory, queue passive or summarization-driven memory updates (including manual `/compact`), expose memory tools, or add memory-tool instructions for that agent. If an existing agent is switched off, its previously injected memory block is removed from checkpoint state before the next model call while its date reminder and conversation remain. Omitting the field (or setting it to `true`) preserves the existing global `memory` behavior.

View File

@ -777,6 +777,7 @@ def _get_memory_context(
*,
app_config: AppConfig | None = None,
user_id: str | None = None,
query: str | None = None,
) -> str:
"""Get memory context for injection into system prompt.
@ -786,6 +787,10 @@ def _get_memory_context(
are read from this value instead of the global config singleton.
user_id: Explicit user bucket. When omitted, resolves the current
Gateway or standalone LangGraph Server identity.
query: Optional current-turn query hint forwarded to the memory
backend. Backends that enable query-aware ranking (DeerMem
``retrieval_relevance_enabled``) rank injected facts against it;
others ignore it.
Returns:
Formatted memory context string wrapped in XML tags, or empty string if disabled.
@ -795,6 +800,7 @@ def _get_memory_context(
config = None
try:
from deerflow.agents.memory import get_memory_manager
from deerflow.agents.memory.manager import context_query_kwargs
from deerflow.runtime.user_context import resolve_runtime_user_id
if app_config is None:
@ -807,9 +813,11 @@ def _get_memory_context(
if not config.enabled or not config.injection_enabled:
return ""
memory_content = get_memory_manager().get_context(
manager = get_memory_manager()
memory_content = manager.get_context(
user_id=user_id or resolve_runtime_user_id(None),
agent_name=agent_name,
**context_query_kwargs(manager.get_context, query),
)
if not memory_content.strip():

View File

@ -300,3 +300,45 @@ Latin words and CJK bigrams both participate in mixed-script similarity.
Whitespace-separated CJK runs retain adjacent-character ordering.
INFO logs identify the target and proposal index without memory content and
explicitly describe a proposed merge, not a completed persistence audit.
#### Relevance-aware retrieval (opt-in)
The deterministic lexical strategy behind issue #4495 lives in
`deermem/core/relevance.py` (token overlap + idf weights + confidence blend +
greedy MMR diversity). It never touches the persisted memory format and never
runs by default.
- `retrieval_relevance_enabled: true` opts in. `memory_search` then ranks every
fact in scope (not only literal substring matches) and prompt injection ranks
facts against the current query before the token-budget selection.
This takes precedence over `retrieval_adapter`: search bypasses FTS5/custom
retrieval, while adapter indexing and warm-up remain configured.
- Ranking reads at most 4096 characters and 128 tokens per query/fact. The
no-jieba fallback emits both Latin words and CJK bigrams, including mixed text.
`DeerMem.warm()` initializes optional jieba before serving requests, even
with character-based token counting. Invalid/missing confidence defaults to 0.
- Search stops MMR after `top_k` picks. Injection diversifies guaranteed and
regular pools independently and lazily, stopping when each token budget is
exhausted; it never truncates candidates before the guaranteed partition.
MMR caches token sets and incrementally updates maximum similarity penalties.
- `retrieval_relevance_weight` blends lexical relevance with confidence;
`retrieval_diversity_weight` demotes near-duplicate facts. Defaults preserve
legacy ordering. Relevance is distinct-query-token IDF coverage; repeated
content cannot replace missing terms or saturate a partial match.
Prefix matching requires one complete token to prefix the other; a shared
four-character bucket alone is not a match (Postman is not PostgreSQL).
- DeerMem injection builds IDF once from the selected user/agent fact scope,
before guaranteed/regular partitioning, using the same bounded tokenizer as
search. No IDF work runs without an active lexical query. Category-filtered
search uses its filtered corpus; budgets and separate diversity pools can
still produce different final selections. No IDF cache crosses calls/scopes.
- The current-turn query flows from `DynamicContextMiddleware` (bounded,
user-message text) through the optional `query` keyword on
`MemoryManager.get_context` / `aget_context`. Shared signature inspection
omits `query` when it is `None` or the backend is old/uninspectable, preserving
forwarding wrappers' absent-hint contract; backend errors never cause retries.
Query extraction prefers preserved `original_user_content` before applying
the character cap, so upload descriptions never displace the user's request.
Attachment-only messages with an empty preserved request stay query-less.
- Ranking must be deterministic, network-free, and mutation-free: caller-owned
fact dicts are read-only inputs.

View File

@ -44,6 +44,7 @@ from .deermem.core.message_processing import (
from .deermem.core.paths import DEFAULT_AGENT_BUCKET
from .deermem.core.prompt import format_memory_for_injection, load_prompt, load_prompt_messages, warm_tiktoken_cache
from .deermem.core.queue import MemoryUpdateQueue, QueueFull
from .deermem.core.relevance import build_idf, order_facts_for_query, tokenize, warm_tokenizer
from .deermem.core.storage import MemoryRevisionConflict, MemoryStorageCorruption, create_storage
from .deermem.core.updater import MemoryUpdater, _coerce_source_confidence
@ -300,6 +301,7 @@ class DeerMem(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
"""Load memory and format it for injection (plain text, no wrap).
@ -308,6 +310,11 @@ class DeerMem(MemoryManager):
facts stay behind ``memory_search`` so they are not duplicated in the
prompt and a later retrieval result.
When ``query`` is provided and ``retrieval_relevance_enabled`` is
true, facts are ranked against the query (lexical relevance combined
with confidence, then optional diversity) before the token-budget
selection. Without a query the legacy confidence ordering is kept.
Format parameters come from DeerMem's own ``DeerMemConfig`` (set at
construction from ``backend_config``). The ``enabled``/
``injection_enabled`` gate and the ``<memory>`` wrapping stay at the
@ -315,12 +322,24 @@ class DeerMem(MemoryManager):
"""
injection_agent = None if self.mode == "tool" else _resolve_agent_name(agent_name)
memory_data = _call_backend(lambda: self._updater.get_memory_data(agent_name=injection_agent, user_id=user_id))
relevance_enabled = self._config.retrieval_relevance_enabled and bool(query and query.strip())
corpus_idf = None
if relevance_enabled and self._config.retrieval_relevance_weight > 0:
# Use the selected user/agent corpus, before budget-pool partitioning,
# with the same bounded tokenizer and IDF as unfiltered search.
facts = memory_data.get("facts", [])
if isinstance(facts, list) and facts:
corpus_idf = build_idf([tokenize(fact["content"]) for fact in facts if isinstance(fact, dict) and isinstance(fact.get("content"), str)])
return format_memory_for_injection(
memory_data,
max_tokens=self._config.max_injection_tokens,
use_tiktoken=(self._config.token_counting == "tiktoken"),
guaranteed_categories=self._config.guaranteed_categories,
guaranteed_token_budget=self._config.guaranteed_token_budget,
query=query if relevance_enabled else None,
relevance_weight=self._config.retrieval_relevance_weight if relevance_enabled else None,
diversity_weight=self._config.retrieval_diversity_weight if relevance_enabled else None,
idf=corpus_idf,
)
def search(
@ -336,18 +355,31 @@ class DeerMem(MemoryManager):
Retrieval errors never make canonical memory unavailable: the existing
case-insensitive substring path remains the last-resort fallback.
With ``retrieval_relevance_enabled``, the opt-in relevance-aware
strategy ranks every fact in scope (adapter-free, deterministic) and
may return related facts without a literal substring match.
"""
if not query or not query.strip() or top_k <= 0:
return []
resolved_agent_name = _resolve_agent_name(agent_name)
indexed = self._fts5_search(query, top_k=top_k, user_id=user_id, agent_name=resolved_agent_name, category=category)
results = indexed or self._substring_search(
query,
top_k=top_k,
user_id=user_id,
agent_name=resolved_agent_name,
category=category,
)
if self._config.retrieval_relevance_enabled:
results = self._relevance_search(
query,
top_k=top_k,
user_id=user_id,
agent_name=resolved_agent_name,
category=category,
)
else:
indexed = self._fts5_search(query, top_k=top_k, user_id=user_id, agent_name=resolved_agent_name, category=category)
results = indexed or self._substring_search(
query,
top_k=top_k,
user_id=user_id,
agent_name=resolved_agent_name,
category=category,
)
if results and (self._config.fact_eviction_policy == EVICTION_POLICY_HYBRID_V1 or self._config.fact_eviction_shadow_enabled):
try:
self._storage.record_fact_accesses(
@ -410,6 +442,38 @@ class DeerMem(MemoryManager):
matched.sort(key=_coerce_source_confidence, reverse=True)
return _compat_document({"facts": matched[:top_k]})["facts"]
def _relevance_search(
self,
query: str,
*,
top_k: int,
user_id: str | None,
agent_name: str | None,
category: str | None,
) -> list[dict[str, Any]]:
"""Opt-in deterministic ranking over every fact in scope (issue #4495).
Candidates are NOT limited to literal substring matches: all facts in
the requested user/agent scope compete, ranked by lexical relevance
combined with confidence, then diversified. The category filter still
applies before the ``top_k`` slice. Caller-owned fact dicts are never
mutated.
"""
memory_data = _call_backend(lambda: self._updater.get_memory_data(agent_name=agent_name, user_id=user_id))
facts = [fact for fact in memory_data.get("facts", []) if isinstance(fact.get("content"), str) and (category is None or fact.get("category") == category)]
if not facts:
return []
corpus_idf = build_idf([tokenize(fact["content"]) for fact in facts])
ranked = order_facts_for_query(
facts,
query,
relevance_weight=self._config.retrieval_relevance_weight,
diversity_weight=self._config.retrieval_diversity_weight,
idf=corpus_idf,
limit=top_k,
)
return _compat_document({"facts": ranked})["facts"]
def _ensure_retrieval_scopes(self, scopes: list[dict[str, str | None]]) -> None:
"""Lazily rebuild every requested scope when warm-up was skipped."""
if not hasattr(self, "_retrieval_lock"):
@ -533,6 +597,8 @@ class DeerMem(MemoryManager):
or warming was unnecessary); False if tiktoken is unavailable or the
download failed.
"""
if self._config.retrieval_relevance_enabled:
warm_tokenizer()
if self._config.token_counting == "char":
logger.info("token_counting='char'; tiktoken not used, skipping warm-up")
return True

View File

@ -71,7 +71,10 @@ class DeerMemConfig(BaseModel):
)
retrieval_adapter: str = Field(
default="fts5",
description="Retrieval adapter factory: 'fts5' (default), an empty string to disable, or a dotted factory receiving DeerMemConfig and implementing RetrievalPort.",
description=(
"Retrieval adapter factory: 'fts5' (default), an empty string to disable, or a dotted factory receiving DeerMemConfig and implementing RetrievalPort. "
"Search bypasses this adapter when retrieval_relevance_enabled is true; indexing remains configured."
),
)
fact_dedup_enabled: bool = Field(
default=False,
@ -94,6 +97,30 @@ class DeerMemConfig(BaseModel):
le=1.0,
description=("Minimum bounded token-Jaccard similarity for the write-side near-duplicate merge gate. Used only when fact_dedup_enabled is true."),
)
retrieval_relevance_enabled: bool = Field(
default=False,
description=(
"Opt-in relevance-aware retrieval (issue #4495). When true, "
"search bypasses retrieval_adapter (including FTS5 and custom factories); "
"memory_search ranks all facts in scope by deterministic lexical "
"relevance combined with confidence, related facts are returned "
"even without a literal substring match, and prompt injection "
"ranks facts against the current query. False preserves the "
"legacy confidence-based behavior exactly."
),
)
retrieval_relevance_weight: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description="Weight of lexical relevance vs confidence in the combined retrieval score. 0.0 = confidence only; 1.0 = relevance only. Used only when retrieval_relevance_enabled is true.",
)
retrieval_diversity_weight: float = Field(
default=0.0,
ge=0.0,
le=1.0,
description="Greedy MMR similarity penalty that demotes near-duplicate facts during relevance-aware ranking. 0.0 (default) = no diversification. Used only when retrieval_relevance_enabled is true.",
)
# ── Queue ────────────────────────────────────────────────────────────
debounce_seconds: int = Field(
default=30,

View File

@ -8,12 +8,15 @@ import math
import re
import threading
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Any, cast
import yaml
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from .relevance import iter_diversify, score_facts
logger = logging.getLogger(__name__)
@ -383,7 +386,7 @@ def _escape_summary(value: Any) -> str:
def _select_fact_lines(
ranked_facts: list[dict[str, Any]],
ranked_facts: Iterable[dict[str, Any]],
*,
token_budget: int,
use_tiktoken: bool,
@ -470,6 +473,10 @@ def format_memory_for_injection(
use_tiktoken: bool = True,
guaranteed_categories: list[str] | None = None,
guaranteed_token_budget: int = 500,
query: str | None = None,
relevance_weight: float | None = None,
diversity_weight: float | None = None,
idf: dict[str, float] | None = None,
) -> str:
"""Format memory data for injection into system prompt.
@ -491,6 +498,20 @@ def format_memory_for_injection(
point the safety-truncation ceiling is raised to
``max_tokens + guaranteed_actual_usage`` to protect them.
Ignored when *guaranteed_categories* is ``None`` or empty.
query: Optional current-turn query. When provided together with
``relevance_weight``, facts are first ranked by deterministic
lexical relevance combined with confidence (issue #4495) before
the guaranteed/regular partition and budget selection. ``None``
preserves the legacy confidence-only ordering.
relevance_weight: Weight of lexical relevance vs confidence for the
query-aware ranking (0.0 = confidence only). Ignored when
``query`` is ``None``.
diversity_weight: Optional greedy-MMR similarity penalty that demotes
near-duplicate facts during query-aware ranking. Ignored when
``query`` is ``None``.
idf: Optional scope-wide query-term weights, shared by both budget
pools. DeerMem supplies the same corpus IDF as unfiltered search;
direct callers that omit it retain uniform term weights.
Returns:
Formatted memory string for system prompt injection.
@ -586,6 +607,19 @@ def format_memory_for_injection(
valid_facts = [f for f in facts_data if isinstance(f, dict) and isinstance(f.get("content"), str) and f.get("content", "").strip()]
try:
# Score once, then lazily diversify each budget pool. Do not run
# full-scope MMR before the token-budget consumer can stop it.
relevance_ordered = bool(query and query.strip() and relevance_weight is not None)
scores = {}
if relevance_ordered:
scores = {id(fact): score for score, fact in score_facts(valid_facts, query, relevance_weight=relevance_weight, idf=idf)}
def _rank_pool(pool: list[dict[str, Any]]) -> Iterable[dict[str, Any]]:
if not relevance_ordered:
return sorted(pool, key=_confidence_key, reverse=True)
scored = sorted(((scores[id(fact)], fact) for fact in pool), key=lambda pair: pair[0], reverse=True)
return iter_diversify(scored, similarity_weight=diversity_weight or 0.0)
# Partition valid facts into guaranteed vs regular groups.
# Use the *raw* category field (no ``or "context"`` default) so
# a category-less legacy fact is never silently promoted into
@ -604,19 +638,12 @@ def format_memory_for_injection(
cat = raw.strip()
return bool(cat) and cat in effective_guaranteed
guaranteed = sorted(
[f for f in valid_facts if _category_match(f)],
key=_confidence_key,
reverse=True,
)
regular = sorted(
[f for f in valid_facts if not _category_match(f)],
key=_confidence_key,
reverse=True,
)
guaranteed_pool = [f for f in valid_facts if _category_match(f)]
regular_pool = [f for f in valid_facts if not _category_match(f)]
guaranteed, regular = _rank_pool(guaranteed_pool), _rank_pool(regular_pool)
else:
guaranteed = []
regular = sorted(valid_facts, key=_confidence_key, reverse=True)
regular = _rank_pool(valid_facts)
# ── Phase 1: select guaranteed lines ──────────────────────────
header_cost = _count_tokens(facts_header, use_tiktoken=use_tiktoken)

View File

@ -0,0 +1,254 @@
"""Deterministic lexical relevance ranking for DeerMem retrieval.
Pure-Python and network-free helpers behind the optional relevance-aware
retrieval strategy (issue #4495):
- ``lexical_relevance`` idf-weighted token overlap between a query and a
fact's content, plus a containment signal so unsegmented (CJK) text stays
usable without jieba;
- ``score_facts`` / ``rank_facts`` combine lexical relevance with the
existing fact confidence (``relevance_weight * relevance +
(1 - relevance_weight) * confidence``);
- ``diversify`` greedy MMR selection that demotes near-duplicate facts.
All helpers treat caller-owned fact dicts as read-only; ranking returns new
lists. Token matching is case-insensitive with prefix matching for shared
stems (``database``/``databases``), mirroring the retrieval layer's
dependency-free style.
"""
from __future__ import annotations
import math
import re
from collections.abc import Iterator
from itertools import islice
from typing import Any
try:
import jieba
_jieba_available = True
except ImportError: # pragma: no cover - exercised via the tokenizer fallback
_jieba_available = False
_WORD_RE = re.compile(r"[a-zA-Z0-9_]+|[\u3400-\u4dbf\u4e00-\u9fff]+")
#: A token pair overlaps when the tokens are equal or one is a prefix of the
#: other (minimum 4 characters so short words do not over-match).
_PREFIX_MATCH_MIN_CHARS = 4
#: Shared bound for lexical ranking and near-duplicate similarity.
_SIMILARITY_TOKEN_BUDGET = 128
_TEXT_CHAR_BUDGET = 4096
def warm_tokenizer() -> None:
"""Load the optional segmenter's dictionary off the first-request path."""
if _jieba_available:
jieba.initialize()
def tokenize(text: str) -> list[str]:
"""Tokenize at most 4096 characters into at most 128 relevance tokens.
Space-free CJK text without jieba falls back to character bigrams so
Chinese queries still produce deterministic token overlap.
"""
if not text:
return []
lowered = text[:_TEXT_CHAR_BUDGET].strip().lower()
if _jieba_available:
return list(islice((token for token in jieba.cut(lowered) if token.strip()), _SIMILARITY_TOKEN_BUDGET))
def fallback_tokens() -> Iterator[str]:
for match in _WORD_RE.finditer(lowered):
part = match.group()
if "\u3400" <= part[0] <= "\u9fff":
if len(part) == 1:
yield part
else:
for index in range(len(part) - 1):
yield part[index : index + 2]
else:
yield part
return list(islice(fallback_tokens(), _SIMILARITY_TOKEN_BUDGET))
def build_idf(corpus: list[list[str]]) -> dict[str, float]:
"""Smoothed inverse document frequency over a token corpus.
Tokens shared by every document get the smallest weight (1.0); rarer
tokens get larger weights. Tokens absent from the corpus are handled by
``lexical_relevance`` with the default weight.
"""
document_count = len(corpus)
if document_count == 0:
return {}
document_frequency: dict[str, int] = {}
for tokens in corpus:
for token in set(tokens):
document_frequency[token] = document_frequency.get(token, 0) + 1
return {token: math.log((document_count + 1) / (frequency + 1)) + 1.0 for token, frequency in document_frequency.items()}
def lexical_relevance(
query: str,
content: str,
*,
idf: dict[str, float] | None = None,
) -> float:
"""IDF-weighted query coverage in ``[0, 1]``.
A containment signal (whole query inside the content, or vice versa)
contributes one matched unit so unsegmented text such as CJK
content still scores above zero without a segmenter.
"""
query_text = (query or "")[:_TEXT_CHAR_BUDGET].strip().lower()
return _lexical_relevance(query_text, tokenize(query_text), content, idf=idf)
def _lexical_relevance(query_text: str, query_tokens: list[str], content: str, *, idf: dict[str, float] | None) -> float:
"""Score coverage of a prepared query against bounded content tokens."""
content_text = (content or "")[:_TEXT_CHAR_BUDGET].strip().lower()
if not query_text or not content_text:
return 0.0
content_tokens = tokenize(content_text)
containment = (query_text in content_text) or (content_text in query_text)
if not query_tokens and not containment:
return 0.0
weights = idf or {}
content_set = set(content_tokens)
prefix_buckets: dict[str, list[str]] = {}
for content_token in content_set:
if len(content_token) >= _PREFIX_MATCH_MIN_CHARS:
prefix_buckets.setdefault(content_token[:_PREFIX_MATCH_MIN_CHARS], []).append(content_token)
# Each distinct query token contributes its squared IDF at most once.
# Compare the matched-query norm to the complete-query norm: repetition
# cannot replace missing terms, and the result needs no clipping. The
# norm ratio keeps useful partial matches competitive in confidence blends.
matched_weight = total_weight = 1.0 if containment else 0.0
for token in dict.fromkeys(query_tokens):
weight = weights.get(token, 1.0) ** 2
total_weight += weight
# Sharing four characters only narrows the candidate set. Count a
# match only when one complete token is a prefix of the other.
if token in content_set or (len(token) >= _PREFIX_MATCH_MIN_CHARS and any(token.startswith(candidate) or candidate.startswith(token) for candidate in prefix_buckets.get(token[:_PREFIX_MATCH_MIN_CHARS], ()))):
matched_weight += weight
return math.sqrt(matched_weight / total_weight) if total_weight > 0.0 else 0.0
def _coerce_confidence(fact: dict[str, Any]) -> float:
try:
value = float(fact.get("confidence"))
if not math.isfinite(value):
raise ValueError
except (TypeError, ValueError):
return 0.0
return min(1.0, max(0.0, value))
def score_facts(
facts: list[dict[str, Any]],
query: str,
*,
relevance_weight: float = 0.5,
idf: dict[str, float] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""Return ``(combined_score, fact)`` pairs sorted descending (no mutation).
``relevance_weight == 0`` short-circuits to the legacy confidence-only
ordering without computing relevance.
"""
if relevance_weight <= 0.0:
return [
(confidence, fact)
for confidence, fact in sorted(
((_coerce_confidence(fact), fact) for fact in facts),
key=lambda pair: pair[0],
reverse=True,
)
]
query_text = (query or "")[:_TEXT_CHAR_BUDGET].strip().lower()
query_tokens = tokenize(query_text)
scored: list[tuple[float, dict[str, Any]]] = []
for fact in facts:
content = fact.get("content")
relevance = _lexical_relevance(query_text, query_tokens, content, idf=idf) if isinstance(content, str) else 0.0
confidence = _coerce_confidence(fact)
combined = relevance_weight * relevance + (1.0 - relevance_weight) * confidence
scored.append((combined, fact))
scored.sort(key=lambda pair: pair[0], reverse=True)
return scored
def rank_facts(
facts: list[dict[str, Any]],
query: str,
*,
relevance_weight: float = 0.5,
idf: dict[str, float] | None = None,
) -> list[dict[str, Any]]:
"""Convenience wrapper: ``score_facts`` without the scores."""
return [fact for _, fact in score_facts(facts, query, relevance_weight=relevance_weight, idf=idf)]
def diversify(
scored: list[tuple[float, dict[str, Any]]],
*,
similarity_weight: float,
limit: int | None = None,
) -> list[dict[str, Any]]:
"""Greedy MMR over ``(score, fact)`` pairs; returns facts (no mutation).
``similarity_weight == 0`` returns the score order unchanged.
"""
ordered = iter_diversify(scored, similarity_weight=similarity_weight)
return list(islice(ordered, max(0, limit))) if limit is not None else list(ordered)
def iter_diversify(scored: list[tuple[float, dict[str, Any]]], *, similarity_weight: float) -> Iterator[dict[str, Any]]:
"""Lazy MMR: tokenize each fact once and update penalties once per pick.
Consumers can stop at their result or token budget without ranking the rest.
Equal adjusted scores retain their input order.
"""
if similarity_weight <= 0.0:
yield from (fact for _, fact in scored)
return
token_sets = [set(tokenize(fact["content"])) if isinstance(fact.get("content"), str) else set() for _, fact in scored]
remaining = list(range(len(scored)))
penalties = [0.0] * len(scored)
while remaining:
best_index = 0
best_adjusted = -math.inf
for index, fact_index in enumerate(remaining):
adjusted = scored[fact_index][0] - similarity_weight * penalties[fact_index]
if adjusted > best_adjusted:
best_adjusted = adjusted
best_index = index
picked_index = remaining.pop(best_index)
yield scored[picked_index][1]
picked_tokens = token_sets[picked_index]
for fact_index in remaining:
tokens = token_sets[fact_index]
similarity = len(tokens & picked_tokens) / len(tokens | picked_tokens) if tokens and picked_tokens else 0.0
penalties[fact_index] = max(penalties[fact_index], similarity)
def order_facts_for_query(
facts: list[dict[str, Any]],
query: str,
*,
relevance_weight: float = 0.5,
diversity_weight: float = 0.0,
idf: dict[str, float] | None = None,
limit: int | None = None,
) -> list[dict[str, Any]]:
"""Score by relevance+confidence, then diversify; used by search/injection."""
scored = score_facts(facts, query, relevance_weight=relevance_weight, idf=idf)
return diversify(scored, similarity_weight=diversity_weight, limit=limit)

View File

@ -205,6 +205,7 @@ class HonchoMemoryManager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
workspace = self._workspace(user_id)
if workspace is None or not user_id:
@ -290,8 +291,9 @@ class HonchoMemoryManager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
return await asyncio.to_thread(self.get_context, user_id, agent_name=agent_name, thread_id=thread_id)
return await asyncio.to_thread(self.get_context, user_id, agent_name=agent_name, thread_id=thread_id, query=query)
async def asearch(
self,

View File

@ -187,10 +187,11 @@ class Mem0Manager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
"""Query-less recall: the contract passes no current query, so inject
the bucket's most recent memories (top_k). Query-aware recall is
available via search() in mode="tool"."""
"""Query-less recall: this backend ignores the optional ``query``
hint and injects the bucket's most recent memories (top_k).
Query-aware recall is available via search() in mode="tool"."""
filters = _build_filters(user_id=user_id, agent_name=agent_name, run_id=thread_id)
if filters is None:
return ""
@ -246,12 +247,14 @@ class Mem0Manager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
return await asyncio.to_thread(
self.get_context,
user_id,
agent_name=agent_name,
thread_id=thread_id,
query=query,
)
# ── Tier 2: search ───────────────────────────────────────────────────

View File

@ -122,6 +122,7 @@ class NoopMemoryManager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
return ""

View File

@ -166,6 +166,7 @@ class OpenVikingMemoryManager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
if not self._begin_operation():
return ""
@ -204,12 +205,14 @@ class OpenVikingMemoryManager(MemoryManager):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
return await asyncio.to_thread(
self.get_context,
user_id,
agent_name=agent_name,
thread_id=thread_id,
query=query,
)
def search(

View File

@ -17,11 +17,13 @@ do not impede pluggability.
from __future__ import annotations
import importlib
import inspect
import logging
import os
import sys
import threading
from abc import abstractmethod
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
from typing import Any, ClassVar, Literal
@ -44,6 +46,23 @@ _backends_cache: dict[str, type[MemoryManager]] | None = None
_manager_lock = threading.Lock()
def context_query_kwargs(get_context: Callable[..., str], query: str | None) -> dict[str, str | None]:
"""Pass the optional hint only when a backend accepts that keyword.
Older plugins need no signature change. An uninspectable callable keeps
the old call contract; backend TypeErrors must never trigger a retry.
"""
if query is None:
return {}
try:
parameters = inspect.signature(get_context).parameters.values()
except (TypeError, ValueError):
return {}
if any(parameter.kind == inspect.Parameter.VAR_KEYWORD or (parameter.name == "query" and parameter.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)) for parameter in parameters):
return {"query": query}
return {}
class MemoryCallbacks:
"""Observability hooks for memory backends. Default implementations are
no-ops; override the ones you need. The pre-LLM-call hook
@ -255,9 +274,15 @@ class MemoryManager(BaseModel):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
"""Return injection-ready memory text for the given bucket.
``query`` is an optional current-turn query hint. Backends that
support query-aware ranking (DeerMem with
``retrieval_relevance_enabled``) may rank injected facts against it;
other backends ignore it. ``None`` must preserve legacy behavior.
Implementations load their memory and format it however they choose;
the returned string is injected verbatim by call sites. Format
parameters are the backend's own private config (received via
@ -530,8 +555,9 @@ class MemoryManager(BaseModel):
*,
agent_name: str | None = None,
thread_id: str | None = None,
query: str | None = None,
) -> str:
return self.get_context(user_id, agent_name=agent_name, thread_id=thread_id)
return self.get_context(user_id, agent_name=agent_name, thread_id=thread_id, **context_query_kwargs(self.get_context, query))
async def asearch(
self,

View File

@ -47,7 +47,7 @@ import os
import posixpath
import re
import uuid
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime, tzinfo
from typing import TYPE_CHECKING, override
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@ -61,7 +61,7 @@ from langgraph.runtime import Runtime
from deerflow.projects.context import build_project_context_message, is_project_context_message, pinned_project_snapshot, project_context_insertion_index, render_documents_block, render_project_block
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.utils.messages import INJECTED_USER_MESSAGE_ID_SUFFIX, strip_injected_user_message_id_suffix
from deerflow.utils.messages import INJECTED_USER_MESSAGE_ID_SUFFIX, ORIGINAL_USER_CONTENT_KEY, strip_injected_user_message_id_suffix
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
@ -74,6 +74,11 @@ logger = logging.getLogger(__name__)
# This cap ensures the request degrades gracefully instead of hanging.
_INJECT_TIMEOUT_SECONDS = 5.0
#: Hard bound on the current-turn query forwarded to the memory backend for
#: query-aware fact ranking. Keeps ranking cost deterministic regardless of
#: message length.
_INJECTION_QUERY_MAX_CHARS = 1000
_DATE_RE = re.compile(r"<current_date>([^<]+)</current_date>")
_DYNAMIC_CONTEXT_REMINDER_KEY = "dynamic_context_reminder"
# Authoritative injected date, carried in additional_kwargs of the date
@ -320,6 +325,37 @@ class SubagentDateContextMiddleware(AgentMiddleware):
return None
def _derive_injection_query(message: object) -> str | None:
"""Extract a bounded text query from the user message being injected on.
Prefer the original user text preserved by UploadsMiddleware so file
descriptions cannot consume the query budget. Otherwise handle plain
text and multimodal lists. An empty original request remains query-less.
"""
content = getattr(message, "content", None)
additional_kwargs = getattr(message, "additional_kwargs", None)
if isinstance(additional_kwargs, Mapping):
original_content = additional_kwargs.get(ORIGINAL_USER_CONTENT_KEY)
if isinstance(original_content, str):
content = original_content
if isinstance(content, str):
text = content
elif isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and isinstance(item.get("text"), str):
part = item["text"].strip()
if part:
parts.append(part)
text = " ".join(parts)
else:
return None
text = text.strip()
if not text:
return None
return text[:_INJECTION_QUERY_MAX_CHARS]
class DynamicContextMiddleware(AgentMiddleware):
"""Inject memory and current date as a SystemMessage <system-reminder>.
@ -385,13 +421,17 @@ class DynamicContextMiddleware(AgentMiddleware):
"shelf_index_max_bytes": max_bytes,
}
def _build_full_reminder(self, runtime: Runtime | None = None) -> tuple[str, str | None]:
def _build_full_reminder(self, runtime: Runtime | None = None, *, query: str | None = None) -> tuple[str, str | None]:
"""Return (date_reminder, memory_block | None).
Framework-owned data (date) is separated from user-owned data (memory)
so the downstream SystemMessage carries only framework authority and
memory stays at role:user preventing untrusted content from gaining
system privilege (OWASP LLM01).
``query`` is the optional current-turn text forwarded to the memory
backend for query-aware fact ranking (issue #4495); ``None`` keeps the
legacy confidence-only ordering.
"""
from deerflow.agents.lead_agent.prompt import _get_memory_context
@ -401,6 +441,7 @@ class DynamicContextMiddleware(AgentMiddleware):
self._agent_name,
app_config=self._app_config,
user_id=resolve_runtime_user_id(runtime),
query=query,
)
if injection_enabled
else ""
@ -541,7 +582,7 @@ class DynamicContextMiddleware(AgentMiddleware):
target_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None)
if target_idx is None:
return {"messages": memory_removals} if memory_removals else None
date_reminder, memory_block = self._build_full_reminder(runtime)
date_reminder, memory_block = self._build_full_reminder(runtime, query=_derive_injection_query(messages[target_idx]))
logger.info(
"DynamicContextMiddleware: injecting full reminder (has_memory=%s) into last HumanMessage id=%r",
memory_block is not None,

View File

@ -99,11 +99,11 @@ async def test_abefore_agent_does_not_block_event_loop() -> None:
# event-loop blocking visible to the Blockbuster gate.
original_build = mw._build_full_reminder
def slow_build_reminder(runtime=None):
def slow_build_reminder(runtime=None, *, query=None):
import time
time.sleep(0.05) # 50ms sync sleep — blocks the thread it runs on
return original_build(runtime)
return original_build(runtime, query=query)
with (
mock.patch.object(mw, "_build_full_reminder", slow_build_reminder),
@ -294,11 +294,13 @@ async def test_abefore_agent_propagates_strict_memory_timeout(
),
],
)
@pytest.mark.parametrize("slow_policy", [False, True], ids=["normal_policy", "slow_policy"])
async def test_abefore_agent_policy_resolution_failure_does_not_replace_timeout(
monkeypatch: pytest.MonkeyPatch,
manager_class: str,
backend_config: dict,
api_key: str | None,
slow_policy: bool,
) -> None:
"""An unresolved timeout policy must fail closed with the original cause."""
if api_key is None:
@ -317,26 +319,40 @@ async def test_abefore_agent_policy_resolution_failure_does_not_replace_timeout(
release = threading.Event()
finished = threading.Event()
if slow_policy:
original_policy = mw._read_failures_are_fatal
def delayed_policy(*, allow_io=True):
if not allow_io:
return None
# Exercise a cold worker still resolving policy after the 10ms timeout.
threading.Event().wait(0.05)
return original_policy(allow_io=allow_io)
monkeypatch.setattr(mw, "_read_failures_are_fatal", delayed_policy)
def blocking_inject(state, runtime=None):
started.set()
release.wait(timeout=2)
finished.set()
try:
with (
mock.patch.object(mw, "_inject", blocking_inject),
mock.patch(
"deerflow.agents.middlewares.dynamic_context_middleware._INJECT_TIMEOUT_SECONDS",
0.01,
),
):
with (
mock.patch.object(mw, "_inject", blocking_inject),
mock.patch(
"deerflow.agents.middlewares.dynamic_context_middleware._INJECT_TIMEOUT_SECONDS",
0.01,
),
):
try:
state = {"messages": [HumanMessage(content="Hello", id="msg-1")]}
runtime = SimpleNamespace(context={})
with pytest.raises(MemoryReadError) as exc_info:
await mw.abefore_agent(state, runtime)
finally:
release.set()
assert await asyncio.to_thread(finished.wait, 1)
finally:
# The worker can reach self._inject only after cold policy resolution.
# Keep its mock installed until the worker exits, including on timeout.
release.set()
assert await asyncio.to_thread(finished.wait, 1)
assert isinstance(exc_info.value.__cause__, TimeoutError)
assert started.is_set()

View File

@ -255,6 +255,7 @@ def test_memory_lookup_uses_runtime_user_id():
None,
app_config=None,
user_id="runtime-user",
query="Hi",
)

View File

@ -376,7 +376,7 @@ def test_get_memory_context_uses_explicit_app_config_without_global_config(monke
def fail_get_memory_config():
raise AssertionError("ambient get_memory_config() must not be used when app_config is explicit")
def fake_get_context(user_id, *, agent_name=None, thread_id=None):
def fake_get_context(user_id, *, agent_name=None, thread_id=None, query=None):
captured["agent_name"] = agent_name
captured["user_id"] = user_id
return "remember this"
@ -457,7 +457,7 @@ def test_get_memory_context_prefers_explicit_user_id(monkeypatch):
def fail_resolve_runtime_user_id(runtime):
raise AssertionError("explicit user_id must bypass ambient identity resolution")
def fake_get_context(user_id, *, agent_name=None, thread_id=None):
def fake_get_context(user_id, *, agent_name=None, thread_id=None, query=None):
captured["agent_name"] = agent_name
captured["user_id"] = user_id
return "remember this"

View File

@ -44,7 +44,7 @@ class _MinimalBackend(MemoryManager):
def add(self, thread_id, messages, *, agent_name=None, user_id=None, trace_id=None) -> None:
self._adds.append((thread_id, user_id))
def get_context(self, user_id, *, agent_name=None, thread_id=None) -> str:
def get_context(self, user_id, *, agent_name=None, thread_id=None, query=None) -> str:
return f"ctx:{user_id}"
@classmethod

View File

@ -0,0 +1,578 @@
"""Tests for the optional relevance-aware retrieval strategy (issue #4495).
The strategy is opt-in via DeerMem-private config
(``retrieval_relevance_enabled``) and must never change the default
confidence-based behavior. Coverage:
- deterministic lexical relevance + confidence scoring;
- greedy MMR diversity selection;
- ``DeerMem.search`` relevance mode (including related facts without a
literal substring match);
- prompt-injection fact ordering under a query;
- the DynamicContextMiddleware -> ``_get_memory_context`` query wiring.
"""
from types import SimpleNamespace
import pytest
from langchain_core.messages import HumanMessage
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core.relevance import (
build_idf,
diversify,
lexical_relevance,
rank_facts,
tokenize,
)
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
def _make_fact(content: str, category: str = "context", confidence: float = 0.7) -> dict:
return {
"id": f"fact_test_{hash(content) & 0xFFFFFFFF:08x}",
"content": content,
"category": category,
"confidence": confidence,
"createdAt": "2026-07-09T00:00:00Z",
"source": "test",
}
def _deer_mem_with_facts(facts: list[dict], backend_config: dict | None = None) -> DeerMem:
"""Build a DeerMem whose updater returns the given facts (no disk I/O)."""
mgr = DeerMem(backend_config=backend_config)
mgr._updater = SimpleNamespace(get_memory_data=lambda agent_name=None, *, user_id=None: {"facts": facts})
return mgr
# ---------------------------------------------------------------------------
# Lexical relevance scoring
# ---------------------------------------------------------------------------
class TestLexicalRelevance:
def test_missing_confidence_defaults_to_zero(self):
missing = {"content": "unrelated first"}
low = _make_fact("unrelated second", confidence=0.1)
assert rank_facts([missing, low], "python")[0] is low
def test_optional_segmenter_receives_bounded_input(self, monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core import relevance
seen = []
def cut(text):
seen.append(len(text))
yield from ("token" for _ in range(10000))
monkeypatch.setattr(relevance, "_jieba_available", True)
monkeypatch.setattr(relevance, "jieba", SimpleNamespace(cut=cut), raising=False)
assert len(tokenize("word" * 10000)) == 128
assert seen == [4096]
def test_mixed_cjk_without_jieba(self, monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core import relevance
monkeypatch.setattr(relevance, "_jieba_available", False)
assert {"python", "我喜", "喜欢", "编程"} <= set(tokenize("我喜欢Python编程"))
assert {"你好", "世界"} <= set(tokenize("你好 世界"))
assert lexical_relevance("数据库升级", "Python数据库迁移") > 0
@pytest.mark.parametrize("confidence", [None, "invalid", float("nan"), float("inf")])
def test_invalid_confidence_does_not_outrank_low_confidence(self, confidence):
invalid = _make_fact("unrelated first", confidence=confidence)
low = _make_fact("unrelated second", confidence=0.1)
assert rank_facts([invalid, low], "python")[0] is low
def test_bounded_tokens(self, monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core import relevance
monkeypatch.setattr(relevance, "_jieba_available", False)
assert len(tokenize("word " * 10000)) <= 128
assert len(tokenize("数据库迁移" * 10000)) <= 128
def test_query_tokenized_once_per_ranking(self, monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core import relevance
original = relevance.tokenize
queries = []
def counted(text):
if text == "database migration":
queries.append(text)
return original(text)
monkeypatch.setattr(relevance, "tokenize", counted)
rank_facts([_make_fact(f"python fact {i}") for i in range(100)], "database migration")
assert len(queries) == 1
def test_overlapping_content_scores_higher_than_unrelated(self):
query = "database migration"
related = lexical_relevance(query, "Migrations are managed with alembic and a PostgreSQL database")
unrelated = lexical_relevance(query, "User prefers cooking Italian food on weekends")
assert related > unrelated
def test_zero_for_no_overlap(self):
assert lexical_relevance("python", "User lives in Beijing") == 0.0
def test_case_insensitive(self):
assert lexical_relevance("PYTHON", "User prefers Python") > 0.0
def test_substring_signal_without_word_boundaries(self):
"""CJK / unsegmented content: containment still contributes relevance."""
assert lexical_relevance("Python", "我喜欢Python编程") > 0.0
def test_empty_query_scores_zero(self):
assert lexical_relevance("", "anything") == 0.0
assert lexical_relevance(" ", "anything") == 0.0
class TestIdf:
def test_common_tokens_are_downweighted(self):
corpus = [
tokenize("database migration conventions"),
tokenize("database backup schedule"),
tokenize("database replica lag"),
tokenize("the database is used everywhere"),
]
idf = build_idf(corpus)
assert idf["migration"] > idf["database"]
class TestRankFacts:
def test_combines_relevance_and_confidence(self):
facts = [
_make_fact("User prefers concise answers", confidence=0.95),
_make_fact("Migrations are managed with alembic", confidence=0.5),
]
ranked = rank_facts(facts, "database migration", relevance_weight=0.7)
assert ranked[0]["content"] == "Migrations are managed with alembic"
def test_pure_confidence_when_relevance_weight_is_zero(self):
facts = [
_make_fact("Low", confidence=0.2),
_make_fact("High", confidence=0.9),
]
ranked = rank_facts(facts, "high", relevance_weight=0.0)
assert [f["content"] for f in ranked] == ["High", "Low"]
def test_does_not_mutate_input(self):
facts = [
_make_fact("Migrations are managed with alembic", confidence=0.5),
_make_fact("User prefers concise answers", confidence=0.95),
]
snapshot = [dict(f) for f in facts]
rank_facts(facts, "database migration", relevance_weight=0.7)
assert facts == snapshot
# ---------------------------------------------------------------------------
# Diversity
# ---------------------------------------------------------------------------
class TestDiversify:
def test_incremental_penalties_match_reference_mmr(self):
scored = [(0.9 - (i % 4) * 0.1, _make_fact(f"database {i % 3} fact {i % 5}")) for i in range(20)]
remaining = list(scored)
expected = []
def penalty(fact):
left = set(tokenize(fact["content"]))
return max((len(left & set(tokenize(picked["content"]))) / len(left | set(tokenize(picked["content"]))) for picked in expected), default=0.0)
while remaining:
index = max(range(len(remaining)), key=lambda i: remaining[i][0] - 0.5 * penalty(remaining[i][1]))
expected.append(remaining.pop(index)[1])
for limit in (0, 1, 5, len(scored), len(scored) + 1):
assert diversify(scored, similarity_weight=0.5, limit=limit) == expected[:limit]
def test_limit_preserves_full_prefix(self):
from deerflow.agents.memory.backends.deermem.deermem.core.relevance import order_facts_for_query
facts = [_make_fact(text) for text in ["database migrations", "database migration", "python testing", "Italian cooking"]]
full = order_facts_for_query(facts, "database", diversity_weight=0.5)
assert order_facts_for_query(facts, "database", diversity_weight=0.5, limit=2) == full[:2]
assert order_facts_for_query(facts, "database", diversity_weight=0.5, limit=0) == []
def test_tokenization_is_linear(self, monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core import relevance
calls = []
original = relevance.tokenize
def counted(text):
calls.append(text)
return original(text)
monkeypatch.setattr(relevance, "tokenize", counted)
scored = [(0.7, _make_fact(f"database fact {i}")) for i in range(30)]
diversify(scored, similarity_weight=0.5, limit=5)
assert len(calls) <= len(scored)
def test_promotes_distinct_fact_over_near_duplicate(self):
facts = [
_make_fact("Use ruff for linting"),
_make_fact("Use ruff for linting"),
_make_fact("Deploys go through GitHub Actions"),
]
ranked = rank_facts(facts, "linting", relevance_weight=0.7)
scored = [(1.0 - index * 0.1, fact) for index, fact in enumerate(ranked)]
picked = diversify(scored, similarity_weight=0.5, limit=2)
contents = [fact["content"] for fact in picked]
assert contents[0] == "Use ruff for linting"
assert "Deploys go through GitHub Actions" in contents
assert len(contents) == 2
def test_identity_when_similarity_weight_is_zero(self):
facts = [
_make_fact("Use ruff for linting"),
_make_fact("Deploys go through GitHub Actions"),
]
ranked = rank_facts(facts, "linting", relevance_weight=0.7)
scored = [(1.0 - index * 0.1, fact) for index, fact in enumerate(ranked)]
picked = diversify(scored, similarity_weight=0.0)
assert [f["content"] for f in picked] == [f["content"] for f in ranked]
# ---------------------------------------------------------------------------
# Config defaults
# ---------------------------------------------------------------------------
class TestRelevanceConfig:
def test_defaults_keep_legacy_behavior(self):
config = DeerMemConfig()
assert config.retrieval_relevance_enabled is False
assert config.retrieval_relevance_weight == 0.5
assert config.retrieval_diversity_weight == 0.0
def test_backend_config_accepts_new_knobs(self):
config = DeerMemConfig.from_backend_config(
{
"retrieval_relevance_enabled": True,
"retrieval_relevance_weight": 0.8,
"retrieval_diversity_weight": 0.4,
}
)
assert config.retrieval_relevance_enabled is True
assert config.retrieval_relevance_weight == 0.8
assert config.retrieval_diversity_weight == 0.4
# ---------------------------------------------------------------------------
# DeerMem.search with relevance mode
# ---------------------------------------------------------------------------
class TestRelevanceSearch:
def test_search_passes_top_k_to_mmr(self, monkeypatch):
from deerflow.agents.memory.backends.deermem import deer_mem
facts = [_make_fact(f"database fact {i}") for i in range(30)]
original = deer_mem.order_facts_for_query
limits = []
def ranked(*args, **kwargs):
limits.append(kwargs.get("limit"))
return original(*args, **kwargs)
monkeypatch.setattr(deer_mem, "order_facts_for_query", ranked)
mgr = _deer_mem_with_facts(facts, {"retrieval_relevance_enabled": True, "retrieval_diversity_weight": 0.5, "retrieval_adapter": ""})
assert len(mgr.search("database", top_k=3)) == 3
assert limits == [3]
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("counting", ["char", "tiktoken"])
def test_warms_segmenter_only_when_enabled(self, monkeypatch, enabled, counting):
from deerflow.agents.memory.backends.deermem import deer_mem
calls = []
monkeypatch.setattr(deer_mem, "warm_tokenizer", lambda: calls.append("jieba"))
monkeypatch.setattr(deer_mem, "warm_tiktoken_cache", lambda: calls.append("tiktoken") or True)
mgr = _deer_mem_with_facts([], {"retrieval_relevance_enabled": enabled, "token_counting": counting, "retrieval_adapter": ""})
assert mgr.warm() is True
assert calls == (["jieba"] if enabled else []) + (["tiktoken"] if counting == "tiktoken" else [])
def test_returns_related_fact_without_literal_substring(self):
facts = [
_make_fact("Database migrations are handled with alembic", "project", 0.4),
_make_fact("User prefers concise answers", "preference", 0.9),
]
mgr = _deer_mem_with_facts(
facts,
backend_config={
"retrieval_relevance_enabled": True,
"retrieval_adapter": "",
"retrieval_relevance_weight": 0.7,
},
)
results = mgr.search("how do I add a database migration", top_k=5)
assert results[0]["content"] == "Database migrations are handled with alembic"
assert len(results) == 2 # every fact in scope competes, not only substring matches
def test_relevance_outweighs_confidence(self):
facts = [
_make_fact("User prefers concise answers", "preference", 0.9),
_make_fact("Migrations are managed with alembic", "project", 0.4),
]
mgr = _deer_mem_with_facts(
facts,
backend_config={
"retrieval_relevance_enabled": True,
"retrieval_adapter": "",
"retrieval_relevance_weight": 0.7,
},
)
results = mgr.search("database migration", top_k=5)
assert results[0]["content"] == "Migrations are managed with alembic"
def test_respects_category_filter_and_top_k(self):
facts = [_make_fact(f"Database fact {index}", "project", 0.5) for index in range(6)] + [_make_fact("Unrelated preference", "preference", 0.9)]
mgr = _deer_mem_with_facts(
facts,
backend_config={"retrieval_relevance_enabled": True, "retrieval_adapter": ""},
)
results = mgr.search("database", top_k=3, category="project")
assert len(results) == 3
assert all(fact["category"] == "project" for fact in results)
def test_diversity_dedups_near_duplicates(self):
facts = [
_make_fact("Use ruff for linting", confidence=0.9),
_make_fact("Use ruff for linting", confidence=0.8),
_make_fact("CI lints on every pull request", confidence=0.7),
]
mgr = _deer_mem_with_facts(
facts,
backend_config={
"retrieval_relevance_enabled": True,
"retrieval_adapter": "",
"retrieval_diversity_weight": 0.5,
},
)
# Both "lints" and "linting" extend this complete query token, so
# the test isolates diversity rather than arbitrary shared stems.
results = mgr.search("lint", top_k=2)
assert len(results) == 2
assert "CI lints on every pull request" in [fact["content"] for fact in results]
def test_legacy_behavior_unchanged_when_disabled(self):
facts = [
_make_fact("Fact A", confidence=0.3),
_make_fact("Fact B", confidence=0.9),
]
mgr = _deer_mem_with_facts(facts) # default config
results = mgr.search("Fact", top_k=5)
assert [fact["confidence"] for fact in results] == [0.9, 0.3]
def test_legacy_empty_result_without_substring_match_when_disabled(self):
facts = [_make_fact("The project uses PostgreSQL for persistence")]
mgr = _deer_mem_with_facts(facts)
assert mgr.search("database migration", top_k=5) == []
# ---------------------------------------------------------------------------
# Prompt injection with query-aware ranking
# ---------------------------------------------------------------------------
class TestInjectionRelevance:
def test_diversification_stops_at_budget_and_preserves_guaranteed_pool(self, monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core import prompt
original = prompt.iter_diversify
picked = []
def counted(*args, **kwargs):
for fact in original(*args, **kwargs):
picked.append(fact)
yield fact
monkeypatch.setattr(prompt, "iter_diversify", counted)
facts = [_make_fact(f"database fact {i}", confidence=0.9) for i in range(100)]
facts.append(_make_fact("Always ask before deleting files", category="correction", confidence=0.1))
result = prompt.format_memory_for_injection(
{"facts": facts},
query="database",
relevance_weight=0.7,
diversity_weight=0.5,
**self._injection_args(max_tokens=40, guaranteed_categories=["correction"], guaranteed_token_budget=20),
)
assert "Always ask before deleting files" in result
assert "database fact" in result
assert len(picked) < 10
def _injection_args(self, **overrides):
args = {
"max_tokens": 300,
"use_tiktoken": False,
"guaranteed_categories": None,
"guaranteed_token_budget": 500,
}
args.update(overrides)
return args
def test_relevance_reranks_facts_under_token_budget(self):
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import (
format_memory_for_injection,
)
facts = [
_make_fact("User prefers concise answers", "preference", 0.95),
_make_fact("Migrations are managed with alembic", "project", 0.4),
]
memory_data = {"facts": facts}
legacy = format_memory_for_injection(
memory_data,
**self._injection_args(max_tokens=20),
)
relevance = format_memory_for_injection(
memory_data,
query="how do I add a database migration",
relevance_weight=0.7,
**self._injection_args(max_tokens=20),
)
assert "concise answers" in legacy
assert "alembic" in relevance
assert "alembic" not in legacy
def test_query_none_preserves_legacy_order(self):
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import (
format_memory_for_injection,
)
facts = [
_make_fact("User prefers concise answers", "preference", 0.95),
_make_fact("Migrations are managed with alembic", "project", 0.4),
_make_fact("User lives in Beijing", "personal", 0.8),
]
memory_data = {"facts": facts}
legacy = format_memory_for_injection(memory_data, **self._injection_args())
with_query_none = format_memory_for_injection(memory_data, query=None, relevance_weight=0.7, **self._injection_args())
assert legacy == with_query_none
class TestGetContextQuery:
def test_get_context_uses_query_when_enabled(self):
facts = [
_make_fact("User prefers concise answers", "preference", 0.95),
_make_fact("Migrations are managed with alembic", "project", 0.4),
]
mgr = _deer_mem_with_facts(
facts,
backend_config={"retrieval_relevance_enabled": True, "retrieval_relevance_weight": 0.7},
)
body = mgr.get_context("user-1", agent_name="assistant", query="how do I add a database migration")
assert "alembic" in body
def test_get_context_without_query_keeps_confidence_order(self):
facts = [
_make_fact("User prefers concise answers", "preference", 0.95),
_make_fact("Migrations are managed with alembic", "project", 0.4),
]
enabled = _deer_mem_with_facts(
facts,
backend_config={"retrieval_relevance_enabled": True},
)
disabled = _deer_mem_with_facts(facts)
assert enabled.get_context("user-1", agent_name="assistant") == disabled.get_context("user-1", agent_name="assistant")
# ---------------------------------------------------------------------------
# Middleware wiring
# ---------------------------------------------------------------------------
class TestMiddlewareQueryWiring:
@pytest.mark.parametrize("multimodal", [False, True])
@pytest.mark.parametrize("user_text", ["Use my PostgreSQL preferences to analyze these reports.", "", "PostgreSQL " * 200], ids=["request", "attachment_only", "bounded_request"])
def test_upload_context_does_not_replace_original_query(self, monkeypatch, tmp_path, multimodal, user_text):
from unittest import mock
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
uploads = UploadsMiddleware(base_dir=str(tmp_path))
files = [{"filename": f"report-{i}.csv", "size": 1024, "path": f"/mnt/user-data/uploads/report-{i}.csv", "extension": ".csv"} for i in range(5)]
monkeypatch.setattr(uploads, "_files_from_kwargs", lambda *_: files)
content = [{"type": "text", "text": user_text}] if multimodal else user_text
runtime = SimpleNamespace(context={})
update = uploads.before_agent({"messages": [HumanMessage(content=content, id="msg-1")]}, runtime)
uploaded_message = update["messages"][0]
assert uploaded_message.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == user_text
with mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value="") as get_context:
DynamicContextMiddleware().before_agent({"messages": [uploaded_message]}, runtime)
get_context.assert_called_once()
assert get_context.call_args.kwargs["query"] == (user_text.strip()[:1000] or None)
def test_invalid_original_content_metadata_uses_message_text(self):
from deerflow.agents.middlewares.dynamic_context_middleware import _derive_injection_query
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
message = HumanMessage(content="database migration", additional_kwargs={ORIGINAL_USER_CONTENT_KEY: ["not a string"]})
assert _derive_injection_query(message) == "database migration"
def test_first_turn_passes_current_query_to_memory_context(self):
from unittest import mock
mw = DynamicContextMiddleware()
state = {
"messages": [
HumanMessage(content="how do I add a database migration", id="msg-1"),
]
}
with (
mock.patch(
"deerflow.agents.lead_agent.prompt._get_memory_context",
return_value="",
) as get_context,
mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt,
):
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
mw.before_agent(state, SimpleNamespace(context={}))
get_context.assert_called_once()
assert get_context.call_args.kwargs.get("query") == "how do I add a database migration"
def test_multimodal_content_yields_text_query(self):
from unittest import mock
mw = DynamicContextMiddleware()
state = {
"messages": [
HumanMessage(
content=[
{"type": "text", "text": "how do I "},
{"type": "text", "text": "add a database migration"},
],
id="msg-1",
),
]
}
with (
mock.patch(
"deerflow.agents.lead_agent.prompt._get_memory_context",
return_value="",
) as get_context,
mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt,
):
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
mw.before_agent(state, SimpleNamespace(context={}))
assert get_context.call_args.kwargs.get("query") == "how do I add a database migration"

View File

@ -0,0 +1,230 @@
"""Regression coverage for PR 5251's backend compatibility and IDF review."""
import asyncio
from copy import deepcopy
from types import SimpleNamespace
import pytest
from deerflow.agents.lead_agent.prompt import _get_memory_context
from deerflow.agents.memory import MemoryManager
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
from deerflow.agents.memory.backends.deermem.deermem.core.relevance import build_idf, lexical_relevance, tokenize
class _LegacyBackend(MemoryManager):
@classmethod
def from_config(cls, backend_config, *, mode="middleware", **host_hooks):
return cls(backend_config=backend_config or {}, mode=mode)
def add(self, thread_id, messages, *, agent_name=None, user_id=None, trace_id=None):
pass
def get_context(self, user_id, *, agent_name=None, thread_id=None):
return f"memory:{user_id}:{agent_name}:{thread_id}"
@pytest.mark.parametrize("query", [None, "", "database migration"])
def test_old_backend_signature_keeps_prompt_memory(monkeypatch, query):
manager = _LegacyBackend()
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
config = SimpleNamespace(memory=SimpleNamespace(enabled=True, injection_enabled=True, backend_config={}))
context = _get_memory_context("agent-a", app_config=config, user_id="user-a", query=query)
assert "<memory>" in context
assert "memory:user-a:agent-a:None" in context
@pytest.mark.parametrize("query", [None, "", "database migration"])
def test_old_backend_signature_keeps_inherited_async_context(query):
assert asyncio.run(_LegacyBackend().aget_context("user-a", agent_name="agent-a", thread_id="thread-a", query=query)) == "memory:user-a:agent-a:thread-a"
@pytest.mark.parametrize("accepts_kwargs", [False, True])
def test_query_capable_backend_receives_hint_in_prompt_and_async(monkeypatch, accepts_kwargs):
calls = []
def explicit(self, user_id, *, agent_name=None, thread_id=None, query=None):
calls.append((user_id, agent_name, thread_id, query))
return "query-aware memory"
def variadic(self, user_id, *, agent_name=None, thread_id=None, **kwargs):
return explicit(self, user_id, agent_name=agent_name, thread_id=thread_id, query=kwargs.get("query"))
class QueryBackend(_LegacyBackend):
get_context = variadic if accepts_kwargs else explicit
manager = QueryBackend()
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
config = SimpleNamespace(memory=SimpleNamespace(enabled=True, injection_enabled=True))
assert "query-aware memory" in _get_memory_context("a", app_config=config, user_id="u", query="migration")
assert asyncio.run(manager.aget_context("u", agent_name="a", thread_id="t", query="migration")) == "query-aware memory"
assert calls == [("u", "a", None, "migration"), ("u", "a", "t", "migration")]
def test_backend_typeerror_is_not_retried_as_legacy_signature(monkeypatch):
calls = []
class BrokenBackend(_LegacyBackend):
def get_context(self, user_id, *, agent_name=None, thread_id=None, query=None):
calls.append(query)
raise TypeError("backend implementation failed")
manager = BrokenBackend()
with pytest.raises(TypeError, match="backend implementation failed"):
asyncio.run(manager.aget_context("u", query="migration"))
assert calls == ["migration"]
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
config = SimpleNamespace(memory=SimpleNamespace(enabled=True, injection_enabled=True))
assert _get_memory_context(app_config=config, user_id="u", query="migration") == ""
assert calls == ["migration", "migration"]
def test_uninspectable_legacy_callable_keeps_prompt_memory(monkeypatch):
class LegacyCallable:
__signature__ = object()
def __call__(self, user_id, *, agent_name=None, thread_id=None):
return "legacy callable memory"
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: SimpleNamespace(get_context=LegacyCallable()))
config = SimpleNamespace(memory=SimpleNamespace(enabled=True, injection_enabled=True))
assert "legacy callable memory" in _get_memory_context(app_config=config, user_id="u", query="migration")
def _corpus():
contents = ["Python coding conventions", "Python database migration uses Alembic"] + [f"Unrelated cooking recipe {index}" for index in range(8)]
return [{"id": f"fact_{index}", "content": content, "confidence": 0.7, "category": "context", "createdAt": "2026-01-01T00:00:00Z"} for index, content in enumerate(contents)]
def test_complete_query_coverage_outranks_partial_match_with_corpus_idf():
facts = _corpus()
idf = build_idf([tokenize(fact["content"]) for fact in facts])
partial = lexical_relevance("python database migration", facts[0]["content"], idf=idf)
complete = lexical_relevance("python database migration", facts[1]["content"], idf=idf)
assert 0 < partial < complete <= 1
@pytest.mark.parametrize("partial_first", [False, True])
def test_search_top_one_prefers_complete_match_independent_of_input_order(partial_first):
facts = _corpus()
if not partial_first:
facts[0], facts[1] = facts[1], facts[0]
manager = DeerMem(backend_config={"retrieval_relevance_enabled": True, "retrieval_relevance_weight": 1.0})
manager._updater = SimpleNamespace(get_memory_data=lambda agent_name=None, *, user_id=None: {"facts": facts})
result = manager.search("python database migration", top_k=1)
assert result[0]["content"] == "Python database migration uses Alembic"
@pytest.mark.parametrize(
"query,partial,complete",
[
("python database migration", "python " * 30, "migration database python additional detail"),
("python python database migration", "Python coding conventions", "migration database python"),
("python database migration", "python", "migration database python"),
("python databases migrations", "python database " * 20, "python database migration"),
],
)
def test_partial_matches_cannot_saturate_from_repetition_or_containment(query, partial, complete):
idf = build_idf([tokenize(text) for text in [partial, complete, "unrelated"]])
assert lexical_relevance(query, partial, idf=idf) < lexical_relevance(query, complete, idf=idf)
def test_rare_query_terms_keep_more_weight_than_common_terms():
idf = build_idf([tokenize(text) for text in ["python database migration", "python coding", "python testing", "python packaging"]])
assert lexical_relevance("python database migration", "database", idf=idf) > lexical_relevance("python database migration", "python", idf=idf)
@pytest.mark.parametrize("query,content", [("PostgreSQL", "Postman collections"), ("authorization", "authentication settings"), ("database", "dataframe columns")])
def test_shared_four_character_prefix_is_not_a_lexical_match(query, content):
assert lexical_relevance(query, content) == 0.0
@pytest.mark.parametrize("query,content", [("database", "databases"), ("databases", "database"), ("migration", "migrations"), ("migrations", "migration")])
def test_complete_token_prefixes_still_match(query, content):
assert lexical_relevance(query, content) == 1.0
@pytest.mark.parametrize("unrelated_first", [False, True])
def test_search_prefers_exact_token_over_shared_prefix(unrelated_first):
facts = [
{"id": "unrelated", "content": "Postman collections", "confidence": 0.7, "category": "context"},
{"id": "exact", "content": "PostgreSQL database", "confidence": 0.7, "category": "context"},
]
if not unrelated_first:
facts.reverse()
manager = DeerMem(backend_config={"retrieval_relevance_enabled": True, "retrieval_relevance_weight": 1.0})
manager._updater = SimpleNamespace(get_memory_data=lambda agent_name=None, *, user_id=None: {"facts": facts})
assert manager.search("PostgreSQL", top_k=1)[0]["id"] == "exact"
def test_absent_query_keeps_forwarding_wrapper_legacy_contract(monkeypatch):
calls = []
inner = _LegacyBackend()
class ForwardingBackend(_LegacyBackend):
def get_context(self, user_id, **kwargs):
calls.append(kwargs.copy())
return inner.get_context(user_id, **kwargs)
manager = ForwardingBackend()
monkeypatch.setattr("deerflow.agents.memory.get_memory_manager", lambda: manager)
config = SimpleNamespace(memory=SimpleNamespace(enabled=True, injection_enabled=True))
assert "memory:u:a:None" in _get_memory_context("a", app_config=config, user_id="u")
assert asyncio.run(manager.aget_context("u", agent_name="a", thread_id="t")) == "memory:u:a:t"
assert calls == [{"agent_name": "a"}, {"agent_name": "a", "thread_id": "t"}]
def _idf_scope(common):
contents = ["python conventions", "migration conventions"] + [f"{common} background {index}" for index in range(8)]
return {"facts": [{"id": f"fact_{index}", "content": content, "category": "preference" if index < 2 else "context", "confidence": 0.7} for index, content in enumerate(contents)]}
@pytest.mark.parametrize("guaranteed", [[], ["preference"]])
@pytest.mark.parametrize("reverse", [False, True])
def test_search_and_injection_share_scope_idf_without_cross_scope_leakage(guaranteed, reverse):
scopes = {("u1", "a"): _idf_scope("python"), ("u2", "a"): _idf_scope("migration"), ("u1", "b"): _idf_scope("migration")}
if reverse:
for data in scopes.values():
data["facts"].reverse()
original = deepcopy(scopes)
manager = DeerMem(backend_config={"retrieval_relevance_enabled": True, "retrieval_relevance_weight": 1.0, "token_counting": "char", "guaranteed_categories": guaranteed})
manager._updater = SimpleNamespace(get_memory_data=lambda agent_name=None, *, user_id=None: scopes[(user_id, agent_name)])
# Return to the first scope to detect accidental reuse of another user's IDF.
for user, agent in [("u1", "a"), ("u2", "a"), ("u1", "b"), ("u1", "a")]:
search = manager.search("python migration", top_k=10, user_id=user, agent_name=agent)
context = manager.get_context(user, agent_name=agent, query="python migration")
expected, other = ("migration conventions", "python conventions") if (user, agent) == ("u1", "a") else ("python conventions", "migration conventions")
assert search[0]["content"] == expected
assert context.index(expected) < context.index(other)
assert scopes == original
@pytest.mark.parametrize("enabled,query,weight", [(False, "python migration", 1.0), (True, None, 1.0), (True, "", 1.0), (True, " ", 1.0), (True, "python migration", 0.0)])
def test_inactive_relevance_does_not_build_injection_idf(monkeypatch, enabled, query, weight):
def unexpected_idf(corpus):
pytest.fail("IDF must not be built when lexical relevance is unused")
monkeypatch.setattr("deerflow.agents.memory.backends.deermem.deer_mem.build_idf", unexpected_idf)
manager = DeerMem(backend_config={"retrieval_relevance_enabled": enabled, "retrieval_relevance_weight": weight, "token_counting": "char"})
manager._updater = SimpleNamespace(get_memory_data=lambda agent_name=None, *, user_id=None: _idf_scope("python"))
assert manager.get_context("u", agent_name="a", query=query) == manager.get_context("u", agent_name="a")
def test_large_scope_idf_is_bounded_and_keeps_rare_fact_within_budget(monkeypatch):
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import _count_tokens
corpora = []
def observed_idf(corpus):
corpora.append((len(corpus), max(map(len, corpus))))
return build_idf(corpus)
monkeypatch.setattr("deerflow.agents.memory.backends.deermem.deer_mem.build_idf", observed_idf)
facts = [{"id": f"fact_{i}", "content": "python background " * 1000, "category": "context", "confidence": 0.7} for i in range(499)]
facts.append({"id": "fact_rare", "content": "migration conventions", "category": "context", "confidence": 0.7})
manager = DeerMem(backend_config={"retrieval_relevance_enabled": True, "retrieval_relevance_weight": 1.0, "token_counting": "char", "max_injection_tokens": 100, "guaranteed_categories": []})
manager._updater = SimpleNamespace(get_memory_data=lambda agent_name=None, *, user_id=None: {"facts": facts})
context = manager.get_context("u", agent_name="a", query="python migration")
assert "migration conventions" in context
assert _count_tokens(context, use_tiktoken=False) <= 100
assert corpora == [(500, 128)]

View File

@ -113,7 +113,7 @@ class TestDynamicContextMemoryStamping:
from deerflow.agents.middlewares import dynamic_context_middleware as module
monkeypatch.setattr(module.DynamicContextMiddleware, "_build_full_reminder", lambda self, runtime=None: ("<system-reminder></system-reminder>", "some recalled memory"))
monkeypatch.setattr(module.DynamicContextMiddleware, "_build_full_reminder", lambda self, runtime=None, *, query=None: ("<system-reminder></system-reminder>", "some recalled memory"))
middleware = module.DynamicContextMiddleware()
result = middleware._inject({"messages": [HumanMessage(content="hello", id="u1")]})
memory_messages = [m for m in result["messages"] if str(m.id or "").endswith("__memory")]