Skip to content

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

Merged
WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-memory-search-substring-capped-at-100-facts
Oct 12, 2026
Merged

WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-memory-search-substring-capped-at-100-facts

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Why

FileMemoryStorage._search_substring — the search path used when the retrieval adapter is disabled (retrieval_adapter: "" is a documented DeerMemConfig mode) — iterated self.list_facts(filters=filters, **self._scope_kwargs(scope)) (storage.py:1954 on main). list_facts is paginated with a default limit: int = 100 (storage.py:1709) and this caller never paged, so a search only ever saw the first 100 facts of each scope.

With 150 matching facts in one scope, memory_search returned 100 results for top_k=150 and dropped every match past the first page — violating the MemoryManager.search contract, "Search the bucket's memory for facts matching query; return up to top_k ranked by relevance" (manager.py:347-349). The cap propagates to callers because DeerMem.search keeps the indexed results whenever they are non-empty (results = indexed or self._substring_search(...), deer_mem.py:387), so the manager-level uncapped substring fallback never runs.

The scoped rebuild_index path already reads the complete scope for the same reason — "The bulk replace below drops every row this rebuild does not supply, so read the complete scope rather than a list_facts page" (storage.py:2021-2023). A ranking search must see the whole scope too.

What changed

  • _search_substring now reads the complete scope the same way the scoped rebuild does — self.load(**self._scope_kwargs(scope)).get("facts", []) — and applies the filters dict in place with list_facts' exact match semantics.
  • No result-shape change: for scopes with ≤100 matching facts the results are identical; for larger scopes, memory_search now returns up to top_k matches from the whole scope instead of stopping at the first 100. load returns deep copies (storage.py:1474, 1478), so the copy isolation callers previously got through list_facts is unchanged.

Surface area

  • Frontend UI — page / component / setting / interaction under frontend/
  • Backend API — endpoint / SSE event / request-response shape under backend/app
  • Agents / LangGraph — agent node, graph wiring, langgraph.json, or prompt change
  • Sandbox — docker/ or sandboxed execution
  • Skills — change under skills/
  • Dependencies — new/upgraded entry in backend/pyproject.toml or frontend/package.json
  • Default behavior change — with the retrieval adapter disabled, searches over scopes with more than 100 facts now return matches beyond the first 100 (restores the documented top_k contract)
  • Docs / tests / CI only — no runtime behavior change

Bug fix verification

  • Test path that reproduces the bug: backend/tests/test_memory_retrieval_adapter.py::test_substring_fallback_searches_every_fact_of_a_large_scope
  • Did it go red on main and green on this branch? yes
    • red on main: AssertionError: assert 100 == 150 (150 matching facts seeded, top_k=200)
    • green on this branch: 1 passed

Validation

  • cd backend && make lint — All checks passed! / 2037 files already formatted
  • All 23 test files under backend/tests/ that import the touched storage module: 775 passed, 2 skipped in 22.74s
  • End-to-end with retrieval_adapter="" and 150 facts on disk: memory_search("widget", top_k=150) now returns 150 results, 50 of them from facts refactor: stess the time range while using web search tools #100–feat: add searxng search tool support #149 (was 100 before).
  • cd backend && make test (four parallel shards, 29,660 passed): 6 failures, all in deadline/multiprocess-sensitive files unrelated to memory (test_skills_custom_multiprocess.py, test_skillscan_env_review_regressions.py, test_file_outline_atx.py); all 86 tests of those three files pass in isolation on this branch, and none of them import the memory module.

AI assistance

Tool(s) used: Claude Code

How you used it: AI traced the memory_search path, wrote the regression test, and wrote the one-method fix mirroring the scoped rebuild_index pattern; I reviewed every line against the storage contract and the surrounding code.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

_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.
@github-actions github-actions Bot added area:agents Agents, subagents, graph wiring, prompts, langgraph.json needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/S PR changes 20-100 lines labels Oct 12, 2026

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 32ad7b4. The fix is correct and minimal: the new in-place filter is predicate-identical to list_facts (storage.py:1747 on this branch vs the new line 1957), load returns deep copies on both of its return paths so the copy isolation callers got through list_facts is preserved, and both paths already cost one load per scope so there is no read-amplification regression. Nothing in the package subclasses or monkeypatches list_facts, so bypassing it breaks no dispatch, and the manager-level DeerMem._substring_search was already uncapped, confirming this was the only capped path. I ran the new regression test against the PR head (21 passed, 1 skipped in the file) and against main source (fails at the len(results) == 150 assertion with 100 returned), so the red/green claim checks out. One nit on the test's boundary id below.


assert len(results) == 150
returned_ids = {item["fact"]["id"] for item in results}
assert {"fact-100", "fact-101", "fact-150"} <= returned_ids

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: fact-100 is not past-page evidence — the old list_facts page (matched[0:100], insertion order) was exactly fact-001..fact-100, and I confirmed against main source that the pre-fix search returns fact-100 but not fact-101. The ids that actually carry the regression signal are fact-101 and fact-150 (plus the len(results) == 150 assertion above, which is where the red run fails). Consider dropping fact-100 or keeping it with a boundary note so a future reader does not mistake it for proof the search got past the old page.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped fact-100 in db32bd3 and left a boundary note in its place: the assertion now checks {"fact-101", "fact-150"} with a comment stating fact-001..fact-100 sat inside the old first page (matched[0:100], insertion order), so only fact-101/fact-150 are past-page evidence. The len(results) == 150 check stays as the red-run failure point.

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.
@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 12, 2026
@WillemJiang
WillemJiang merged commit f4d10ef into bytedance:main Oct 12, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agents Agents, subagents, graph wiring, prompts, langgraph.json important needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/S PR changes 20-100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants