Skip to content

fix(memory): keep non-core fact categories instead of rewriting to other - #6733

Merged
WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-memory-tools-category-roundtrip-loss
Oct 12, 2026
Merged

WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-memory-tools-category-roundtrip-loss

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Why

memory_add documents free-form category labels — "Category label for organization (default "context"). e.g. "preference", "correction", "behavior", "personal"" (backend/packages/harness/deerflow/agents/memory/tools.py:135-136) — and memory_search promises "Only facts with this exact category are returned" (tools.py:64-65). But storage._normalize_category (backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py:252-254) rewrote every label outside CORE_CATEGORIES to "other", parking the original in a categoryExtension field that no code in the repo reads (its only other reference is the gateway response model, backend/app/gateway/routers/memory.py:76). As a result memory_add(category="personal") — an example label from the tool's own docstring — was persisted as category="other", memory_search(category="personal") could never match it, and get_memory/gateway documents kept reporting "other".

The same rewrite hit two more surfaces: the memory settings UI's category input is free text (frontend/src/components/workspace/settings/memory-settings-page.tsx:804), and the fact-extraction prompt's own category enum includes knowledge (backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/fact_extraction.yaml:12), which is not in CORE_CATEGORIES — so LLM-extracted knowledge facts were also folded into "other".

What changed

  • _normalize_category now keeps the caller's label as the stored category (an empty string still falls back to "context"); it no longer rewrites non-core labels to "other".
  • Non-core labels now round-trip through fact create/update, get_memory documents, the FTS5 retrieval index, and the memory_search category filter.
  • Facts written before this change keep their persisted category/categoryExtension values; the gateway's Fact.categoryExtension field still surfaces those legacy values. No data migration is performed.

Surface area

  • Frontend UI
  • Backend API
  • Agents / LangGraph
  • Sandbox
  • Skills
  • Dependencies
  • Default behavior change — facts created/updated with a non-core category label now persist that label as category instead of "other" plus a write-only categoryExtension.
  • Docs / tests / CI only

Bug fix verification

  • Test path that reproduces the bug: backend/tests/test_deermem_self_contained.py::test_create_fact_category_round_trips_into_search_and_documents
  • Did it go red on main and green on this branch? yes

Validation

Backend (cd backend):

  • make lint → ruff check .: All checks passed!; ruff format --check .: 2037 files already formatted
  • PYTHONPATH=. uv run pytest tests/test_deermem_self_contained.py -q → 43 passed (includes the new regression test)
  • PYTHONPATH=. uv run pytest tests/test_memory_storage.py tests/test_memory_normalize.py tests/test_memory_tools.py tests/test_memory_updater.py tests/test_memory_search.py tests/test_memory_relevance_retrieval.py tests/test_memory_router.py -q → 278 passed
  • PYTHONPATH=. uv run pytest tests/test_memory_consolidation.py tests/test_memory_eviction.py tests/test_memory_fact_dedup.py tests/test_memory_markdown_migration_cli.py tests/test_memory_manager_interface.py tests/test_memory_manager_pluggable.py tests/test_memory_retrieval_adapter.py tests/test_memory_retrieval_index_local.py tests/test_memory_queue.py tests/test_memory_middleware.py tests/test_memory_scope_gate.py tests/test_gateway_memory_shutdown_cancellation.py tests/test_load_memory_sample.py -q → 243 passed, 1 skipped
  • make test (four parallel shards, full offline suite) → 7413 passed, 123 skipped / 7434 passed, 106 skipped / 7408 passed, 130 skipped / 7411 passed, 131 skipped — 29666 passed, 490 skipped, 0 failed. (One earlier make test invocation hit a transient single-shard failure under load whose detail was not captured; two subsequent full runs of the same shards, including a clean make test, passed completely.)

AI assistance

Tool(s) used: ZCode coding agent (GLM model), driving the BlueX888 account.

How you used it: The agent located the bug by reading the memory tool docstrings against the storage normalization code, wrote the regression test and the one-function fix, and ran the red/green, suite, and lint commands listed in Validation.

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

_normalize_category rewrote any category outside CORE_CATEGORIES to
"other" and parked the original label in a write-only
categoryExtension field, so memory_add(category="personal") -- an
example label in the tool's own docstring -- persisted as "other" and
memory_search(category="personal") ("Only facts with this exact category
are returned") never matched it. get_memory documents kept reporting
"other" as well.

Keep the caller's label as the stored category so it round-trips
through search, documents, and the retrieval index.
@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 against head 20ec78f. The fix is correct and well-targeted: I traced every surface that touches fact categories at head — the FTS5 retrieval filter is an exact-match SQL comparison (retrieval.py category = ?), consolidation and dedup compare/strip categories without enum assumptions, eviction only special-cases the core "correction" label, the gateway Fact model takes a free-form string, and the settings UI renders/edits category as free text — so non-core labels now round-trip everywhere memory_add/memory_search/get_memory promise. The regression test covers the create path. One suggestion below on a stale legacy field surviving the update path.

if category not in CORE_CATEGORIES:
fact.setdefault("categoryExtension", category)
fact["category"] = "other"
fact["category"] = raw_category or "context"

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.

Good fix. One follow-up: _normalize_fact deep-copies every key and the update paths carry unknown fields through (updater.update_memory_fact builds the candidate from the stored fact via get_fact/dict(fact)), so a legacy fact persisted as category: "other" + categoryExtension: "personal" that is later updated to category="personal" keeps the stale categoryExtension alongside the now-native category. The gateway still surfaces that field (backend/app/gateway/routers/memory.py:76 documents it as "Extension category when category is 'other'"), so it now contradicts its documented meaning. Consider clearing it here once the label is stored natively, e.g. if fact["category"] != "other": fact.pop("categoryExtension", None), and extending the new regression test with an update-path case (legacy fact rewritten to a non-core category) to pin the cleanup.

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.

Good catch — fixed in ec67df3 with your suggested shape: _normalize_category now drops categoryExtension whenever the category is not "other", so a legacy other/personal fact updated to personal through update_memory_fact no longer persists the stale extension, and the field keeps its documented meaning ("Extension category when category is 'other'").

The update-path case is pinned in test_update_legacy_aliased_fact_to_native_category_clears_extension (kept next to the round-trip test): it stores the fact under the historical alias, rewrites it to the native label, and asserts the extension is gone, the revision bumps, and memory_search(category="personal") now finds it.

@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.

Read-only static review of pinned current revision; verified introduced findings below. No PR code or tests executed.

if category not in CORE_CATEGORIES:
fact.setdefault("categoryExtension", category)
fact["category"] = "other"
fact["category"] = raw_category or "context"

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.

[P2] Recognize legacy category aliases during migration retries

If an older Gateway commits a per-agent legacy JSON migration and stops, or unlink fails, before deleting the JSON, its Markdown fact contains category='other', categoryExtension='personal' while the retained JSON contains category='personal'. That recoverable state previously resumed successfully. This change also normalizes migration candidates, so _migrate_locked now compares personal/no extension with existing other/personal; _migration_equivalent compares both fields and raises MemoryStorageCorruption. Subsequent reads, reloads and migration retries for that agent fail, surfaced as HTTP 500 by the Gateway, instead of completing recovery. Preserve equivalence with the legacy category/extension representation during migration retries without relaxing genuine content conflicts, and add an interrupted pre-change migration regression test.

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.

Reproduced exactly as described — thanks for the trace. The interrupted pre-change state (Markdown committed as other + categoryExtension="personal", legacy JSON retained with personal) recovered fine before this PR and raised MemoryStorageCorruption on every read/reload/retry at head.

Fixed in ec67df3: _migration_equivalent now compares categories through _category_comparison_key, which folds the legacy alias — category="other" + non-empty categoryExtension=<label> compares equal to the native <label>/no-extension — so the retry recognizes its own committed result and finishes recovery (deletes the JSON, adopts the committed Markdown as-is).

Genuine content conflicts are not relaxed: a retained JSON label that disagrees with the committed alias (e.g. work vs other/personal) still raises, with the legacy file preserved. test_interrupted_legacy_migration_retry_recovers_aliased_category builds the historical on-disk state by replaying the pre-change _normalize_category and pins both sides.

A pre-native-category Gateway committed a per-agent legacy JSON migration
as category="other" + categoryExtension=<label> and could stop before
deleting the legacy JSON (crash / failed unlink). The retained JSON holds
the label natively, so _migrate_locked compares "personal" against its
own committed "other"/"personal" alias, raises MemoryStorageCorruption,
and fails every subsequent read, reload and migration retry for that
agent (HTTP 500 at the Gateway) -- a recoverable state the previous
build recovered from.

Fold the legacy alias into _migration_equivalent's category comparison
("other"/<label> equals <label>/no-extension) so retries recognize and
complete recovery, while a genuinely different label still stops the
migration with the legacy file preserved.

Also drop a stale categoryExtension when a fact is normalized away from
"other": _normalize_fact deep-copies unknown fields through, so a legacy
fact updated to its native category kept surfacing the leftover
extension, contradicting the Gateway contract that the field is only
set "when category is 'other'".

Both regressions are pinned by replaying the historical writer in tests.
@github-actions github-actions Bot added size/M PR changes 100-300 lines and removed size/S PR changes 20-100 lines labels Oct 12, 2026
@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 12, 2026
@WillemJiang
WillemJiang merged commit 1b7ca5e into bytedance:main Oct 12, 2026
21 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 needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/M PR changes 100-300 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants