fix(memory): read the complete scope in the substring search fallback (#6732)

* fix(memory): read the complete scope in the substring search fallback

_search_substring iterated list_facts without paginating, so the
adapter-less fallback only ever saw the default page of 100 facts.
With 150 matching facts in a scope (max_facts allows up to 500),
memory_search returned 100 results for top_k=150 and silently dropped
every match past the first page; DeerMem.search keeps these results
whenever they are non-empty, so its uncapped fallback never ran. Read
the complete scope instead, as the scoped index rebuild already does.

* test(memory): drop in-page fact-100 from the past-page assertion

fact-100 was the last id inside list_facts' old first page
(``matched[0:100]`` in insertion order), so its presence does not
distinguish the fixed search from the pre-fix page; fact-101 and
fact-150 (plus the ``len(results) == 150`` check) carry the regression
signal. Keep a boundary note so the remaining ids are not misread.
This commit is contained in:
哈基米 2026-10-12 22:54:02 +08:00 committed by GitHub
parent 0b78051840
commit f4d10ef42d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 31 additions and 2 deletions

View File

@ -1974,8 +1974,11 @@ class FileMemoryStorage(MemoryStorage):
return []
results: list[dict[str, Any]] = []
for scope in scopes:
facts = self.list_facts(filters=filters, **self._scope_kwargs(scope))
for fact in facts:
# The ranking covers the whole scope, so read the complete scope
# rather than a list_facts page.
for fact in self.load(**self._scope_kwargs(scope)).get("facts", []):
if filters and not all(key in fact and fact.get(key) == value for key, value in filters.items()):
continue
content = fact.get("content")
if isinstance(content, str) and query_lower in content.lower():
# 0.0 is a persisted confidence _normalize_fact accepts; only unset/null defaults.

View File

@ -318,3 +318,29 @@ def test_substring_fallback_keeps_zero_confidence_weight(tmp_path: Path) -> None
assert scores == {"zero": 0.0, "half": 0.5}
finally:
storage.close()
def test_substring_fallback_searches_every_fact_of_a_large_scope(tmp_path: Path) -> None:
"""The fallback must read the whole scope, not the first page of 100 facts.
With 150 facts (max_facts allows up to 500) and no retrieval adapter, a
top_k of 200 returned only the first 100 matches: the fallback iterated
list_facts' default page instead of the complete scope, so facts past
the page silently vanished from search results.
"""
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path), max_facts=200))
scope = {"userId": "alice", "agentName": "agent-a"}
try:
facts = [_fact(f"fact-{index:03d}", f"zeta common memory number {index:03d}") for index in range(1, 151)]
storage.apply_changes({"upserts": facts}, user_id="alice", agent_name="agent-a")
results = storage.search_facts("zeta", scopes=[scope], top_k=200)
assert len(results) == 150
returned_ids = {item["fact"]["id"] for item in results}
# fact-001..fact-100 sat inside the old first page too (``matched[0:100]``
# in insertion order), so fact-101 and fact-150 are the ids only a
# complete-scope read can return.
assert {"fact-101", "fact-150"} <= returned_ids
finally:
storage.close()