Skip to content

Python: fix(core): keep distinct non-ASCII memory topics in separate files - #9227

Closed
Yufeng He (he-yufeng) wants to merge 5 commits into
microsoft:mainfrom
he-yufeng:fix/memory-topic-slug-collision
Closed

Yufeng He (he-yufeng) wants to merge 5 commits into
microsoft:mainfrom
he-yufeng:fix/memory-topic-slug-collision

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Motivation & Context

MemoryFileStore derives a topic's filename with _slugify_topic, which strips every character outside [a-z0-9]. Two unrelated topics can therefore collapse onto one file: 旅行计划 and 饮食偏好 both fall through to the memory-topic fallback, café and cafè both become caf. The second write reads back as the first topic with merged memories, and delete_memory_topic on either name removes the shared file. Silent data loss for any non-ASCII deployment, reproduced against current main in #9205.

Description & Review Guide

  • _slugify_topic now returns its existing output unchanged when the derivation is lossless (slug == normalized.lower()), so every safe ASCII stem keeps the exact filename older versions wrote. When the projection discarded information (non-ASCII, accents, case, punctuation), it appends an 8-char sha256 digest of the normalized topic: café -> caf-850f7dc4, 旅行计划 -> memory-topic-f9d6fe13. This is the same collision-resistance standard _storage_key_segment already applies to owner and source segments in this file.
  • Compatibility with stores written by older versions: get_topic/delete_topic fall back to the pre-digest path when the new file is absent, and write_topic absorbs a legacy-named file into the new name so a migrated topic never lists twice.
  • Reviewers: please focus on the legacy read/absorb paths in MemoryFileStore (_legacy_topic_path, _topic_read_candidates) and on whether keeping the readable prefix plus digest is the right trade against a full _storage_key_segment switch (that helper would base32-encode any non-safe topic, losing the readable stems this store exposes in tool output and index pointer lines).
  • Tests cover CJK/Cyrillic-class collisions, accent folding, delete isolation, legacy read-back, migration on rewrite, and slug idempotency through record re-derivation.

Related Issue

Fixes #9205

AI Assistance

  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: implementation and tests were drafted with AI assistance; I verified the reproduction, the fix, and the full core test suite locally.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…files

_slugify_topic strips everything outside [a-z0-9], so two unrelated topics
can collapse onto one filename: 旅行计划 and 饮食偏好 both fall through to
the memory-topic fallback, café and cafè both become caf. The second write
then reads back as the first topic with merged memories, and deleting one
removes the shared file under both names.

Keep the readable slug byte-identical when the derivation is lossless
(slug == normalized.lower(), so existing safe ASCII stems are untouched)
and suffix a short sha256 digest of the normalized topic otherwise, the
same collision-resistance standard _storage_key_segment already applies
to owner and source segments in this file. get_topic and delete_topic
fall back to the pre-digest path when the new file is absent, and
write_topic absorbs a legacy-named file into the new name, so stores
written by older versions stay readable and migrate on first rewrite.

Fixes microsoft#9205
Copilot AI balanced review requested due to automatic review settings October 9, 2026 00:27

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Oct 9, 2026

@ktz03 LI (ktz03) 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.

Tested 61ed9bc18f6941b6e5ad90cfc851c12acac73ec4 through the public memory tools and MemoryFileStore with real temporary Markdown files. Fresh CJK/accented pairs are separated correctly, and safe ASCII stems retain their filenames. Same-topic rewrites also read and migrate files generated by the previous implementation.

There is still a blocking identity check missing from the legacy fallback and cleanup paths. A legacy filename is only a possible match; it can belong to a different topic:

store.write_topic(session, record("café", "accent fact"), source_id="memory")
store.write_topic(session, record("caf", "ASCII fact"), source_id="memory")
store.delete_topic(session, source_id="memory", topic="café")
# Both files are removed; the independent ASCII topic "caf" is now missing.

Reversing the two writes removes caf.md during write_topic("café") itself. The public write_memory tool also still merges caf followed by café into one topic because get_topic("café") accepts caf.md without checking its stored title. The same issue appears during upgrade: a file generated by the previous implementation for café, followed by a write to distinct cafè, absorbs the new fact into café and leaves cafè unreadable.

Please verify that a fallback file belongs to the requested logical topic before reading, migrating, or deleting it, and add both write-order/delete controls alongside the valid same-topic migration tests. This should preserve explicit lookup by slug while preventing the fallback from treating an unrelated topic as a legacy alias.

Validation scope: Windows/Python 3.14.3, 16 public compatibility scenarios, including four upgrade cases using files generated in separate processes by the unchanged implementation. Cached dependencies; no full core suite, remote model, or other-platform validation.

AI assistance: AI-assisted review and reproduction.

The pre-digest fallback path treats any legacy-named file as an alias
for the requested topic. The slug strips accents and non-ASCII text,
so a same-stem file can belong to a different topic: after writing
both "café" and "caf", deleting "café" removed "caf.md" too, writing
"café" absorbed "caf.md", and a pre-digest file for "café" answered
lookups for "cafè".

A legacy-named file now counts as a fallback alias only when the topic
recorded inside the file matches the requested topic; get_topic,
delete_topic, and the write_topic absorb all gate on it. Same-topic
migration of stores written by older versions is unchanged.

Verified with the full agent-framework-core suite: 6728 tests, 0
failures; ruff and pyright clean on the changed files.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, and for running the real-file compatibility matrix. Your three scenarios all reproduced as genuine data loss, and the fix is in 864a5b5.

The gap was that the legacy fallback treated any legacy-named file as an alias for the requested topic. The slug derivation is lossy by design (accents and non-ASCII fold away), so a same-stem file can belong to a different topic. The fix gates every legacy-path use on identity: a legacy-named file is a fallback alias only when the topic recorded in its # <topic> heading matches the requested topic.

  • get_topic: the legacy candidate is only consulted when its stored topic matches, so cafè no longer reads back caf.md and the write tools stop merging the two.
  • delete_topic: only the identity-matched legacy file is removed, so deleting café no longer takes caf.md with it.
  • write_topic absorb: only identity-matched legacy files are unlinked, so writing café no longer absorbs the ASCII caf topic, and a pre-digest café file still migrates into the digested name on rewrite.

Your repros are pinned as regression tests: test_memory_file_store_delete_keeps_unrelated_stem_file, test_memory_file_store_write_keeps_unrelated_stem_file, and test_memory_file_store_legacy_fallback_requires_matching_stored_topic, alongside the existing same-topic migration tests which still pass unchanged.

Verification: full agent-framework-core suite, 6728 tests, 0 failures; ruff and pyright clean on both changed files.

@ktz03 LI (ktz03) 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.

The legacy identity checks in 864a5b5cc75c54aaa7afd0fe2f00915c6e780cd8 address the earlier read/write/delete scenarios. Both write orders now preserve caf separately from café, and the valid same-topic migration controls still work.

There is another upgrade path that fails before a rewrite can migrate the file. list_topics() parses an existing caf.md headed # café and returns the new slug caf-850f7dc4. MemoryContextProvider.before_run() then selects the topic and calls get_topic(topic=entry.slug). That lookup searches for caf-850f7dc4.md, while the only persisted file is still caf.md; the legacy candidate cannot be derived from the already-digested slug. The result is FileNotFoundError, so a relevant prompt aborts during memory preparation.

Minimal reproduction using the actual previous layout and file contents:

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from agent_framework import (
    AgentSession, MemoryContextProvider, MemoryFileStore, Message, SessionContext,
)

async def main():
    with TemporaryDirectory() as root:
        legacy = Path(root) / "memory/alice/memory/topics/caf.md"
        legacy.parent.mkdir(parents=True)
        legacy.write_text(
            "# café\n\nUpdated: 2026-10-09T02:00:00Z\nSessions: -\n\n"
            "## Summary\nlegacy fact\n\n## Memories\n- legacy fact\n",
            encoding="utf-8",
        )
        session = AgentSession(session_id="one")
        session.state["owner"] = "alice"
        store = MemoryFileStore(root, owner_state_key="owner")
        assert store.get_topic(session, source_id="memory", topic="café").memories == ["legacy fact"]
        provider = MemoryContextProvider(store=store, recent_turns=0, max_extractions=0)
        context = SessionContext(
            session_id=session.session_id,
            input_messages=[Message(role="user", contents=["legacy fact"])],
        )
        await provider.before_run(agent=None, session=session, context=context, state={})

asyncio.run(main())
# FileNotFoundError: No memory topic named 'caf-850f7dc4' was found for this owner.

Please cover the complete existing-file → index → selected-topic injection path before a write, keeping the legacy heading identity checks. The index pointers should also resolve to persisted records before migration. Broadly trusting a legacy filename again would reintroduce the issue fixed in the previous revision.

I generated the legacy files with the unchanged implementation in separate processes. café, 旅行计划, and Travel Plans all pass the same automatic injection on the baseline and fail on this head; project-a remains a normal control. Explicit same-topic rewrites migrate the three files and make injection work again. The earlier 16 compatibility scenarios were also repeated successfully. Scope: Windows/Python 3.14.3, public provider/store/tool calls and real temporary files with cached dependencies; no full core suite, external model/service, or other OS validation.

AI assistance: AI-assisted review and reproduction.

list_topics parses a pre-digest file such as caf.md headed "# café"
and returns the current digested slug, but the file on disk still uses
the legacy name. before_run then calls get_topic(entry.slug) for the
selected entry, where neither the digested path nor a legacy candidate
derivable from that slug exists, so memory preparation dies with
FileNotFoundError. Consolidation silently skips the topic the same way.

A record whose file still sits at the pre-digest name for its topic is
now keyed by the on-disk stem, so MEMORY.md pointers and
get_topic(entry.slug) resolve to the persisted file before a rewrite
migrates it. The heading identity check on the fallback paths is
unchanged: a same-stem file owned by a different topic still does not
alias.

Verified with the full agent-framework-core suite: 8483 tests, 0
failures; ruff and pyright clean on the changed files.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Both upgrade paths are covered in 1ba70db, thanks for the precise repro.

The break was in list_topics(): it parses caf.md headed # café and returns the current digested slug caf-850f7dc4, while the file on disk still uses the pre-digest name. before_run() then selects the entry and calls get_topic(topic="caf-850f7dc4"), where neither the digested path nor a legacy candidate derivable from that slug exists, so memory preparation died with FileNotFoundError. Consolidation silently skipped the same topics through the same chain.

The fix re-keys such records by the stem that actually exists on disk, only when the stem is exactly the pre-digest derivation of the parsed topic. MEMORY.md pointers and get_topic(entry.slug) then resolve to the persisted file before any rewrite migrates it, and an explicit same-topic rewrite still moves caf.md to the digested name. The heading identity checks from 864a5b5 are untouched: a same-stem file owned by a different topic still does not alias, so cafè keeps its FileNotFoundError.

Your repro is pinned as test_memory_context_provider_loads_unmigrated_legacy_topic (provider-level before_run over a real legacy file) plus test_memory_file_store_lists_pre_digest_topic_under_its_on_disk_stem (index pointer resolution and the cafè guard). I also ran your café / 旅行计划 / Travel Plans injection matrix and the post-migration control: all pass. Full agent-framework-core suite green, 8483 tests, 0 failures; ruff and pyright clean on the changed files.

if (path := self._legacy_topic_path(session, source_id=source_id, topic=value)) is not None
}
for legacy_path in legacy_paths:
if legacy_path.exists() and self._topic_file_matches(legacy_path, topic=record.topic):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) This cleanup can delete the file it just wrote when an explicit custom slug matches the legacy derived stem. For example, topic "café" with slug "caf" writes topics/caf.md, then derives the same path as legacy and unlinks it. Please exclude the current topic_path from legacy cleanup (and cover this explicit-slug case) so a valid public slug override cannot lose the topic.

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.

Done in 7579c98. The legacy-absorb set now excludes the path just written, so topic "café" with explicit slug "caf" keeps topics/caf.md instead of unlinking it in the same call. Covered by test_memory_file_store_write_with_explicit_slug_survives_legacy_cleanup, which fails against the previous code (the file is gone right after the write) and passes now; the full test_harness_memory.py file is green (37 passed) with ruff check and format clean.

Writing a topic whose explicit slug collides with the legacy stem
re-derived from the topic deleted the file it had just written: the
legacy path set was filtered against the topic-derived path only, not
against the actual target. Skip the written path when absorbing
pre-digest files and cover the explicit-slug case.
def _slugify_topic(topic: str) -> str:
normalized = _normalize_topic(topic)
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")
if slug == normalized.lower():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) Lowercasing is still treated as lossless here, so distinct preserved topics such as ProjectX and projectx both use projectx.md; the second write merges the first record and deleting either topic deletes the shared file. Please treat case folding as lossy and add a case-only collision regression.

@eavanvalkenburg

Copy link
Copy Markdown
Member

/review

@github-actions github-actions Bot 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.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (4 commit(s)): 61ed9bc18f69, 864a5b5cc75c, 1ba70dbd4afd, 7579c982e40f
Model: gpt-5.6-sol

Overview

The review found 4 verified inline finding(s).

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
4 verified findings remained after source verification (2 high, 2 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_harness/_memory.py

# punctuation): two distinct topics can share it. Suffix a digest of the
# normalized topic so byte-distinct topics get distinct files.
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
return f"{slug}-{digest}" if slug else f"memory-topic-{digest}"

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.

The generated name shares the same namespace as literal safe topics: café and the literal topic caf-850f7dc4 both resolve to caf-850f7dc4.md. The second write therefore overwrites the first, and reads or deletes can target the other topic. Please make lossy-derived names disjoint from literal topic names (or otherwise detect and allocate around occupied identities) so both topics can coexist.

# The readable slug is a lossy projection (non-ASCII, accents, case,
# punctuation): two distinct topics can share it. Suffix a digest of the
# normalized topic so byte-distinct topics get distinct files.
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]

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.

Eight hex characters provide only 32 bits of collision resistance, so same-prefix collisions are practical; for example, 旅行丂浮 and 旅行丅召 both become memory-topic-2c95bf3b.md. A second write then replaces the first topic and either name can read or delete the shared file. Please retain enough of the digest to make collisions infeasible, consistent with the full SHA-256 storage-key fallback used elsewhere in the package.

# punctuation): two distinct topics can share it. Suffix a digest of the
# normalized topic so byte-distinct topics get distinct files.
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
return f"{slug}-{digest}" if slug else f"memory-topic-{digest}"

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.

_slugify_topic adds nine uncapped bytes to losy slugs, so previously writable long topics can now raise ENAMETOLONG; any fix must preserve distinct, idempotent slugs and legacy-safe names while reserving space for .md.tmp.<pid>.

return MemoryTopicRecord.from_markdown(topic_path.read_text(encoding="utf-8"), fallback_topic=topic)
for topic_path in self._topic_read_candidates(session, source_id=source_id, topic=topic):
if topic_path.exists():
return MemoryTopicRecord.from_markdown(topic_path.read_text(encoding="utf-8"), fallback_topic=topic)

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.

When this loads a record written with the documented stable slug override, parsing derives a new slug from the topic instead of retaining the on-disk stem. For example, topic="café", slug="caf" reloads with slug="caf-850f7dc4"; the next merge writes that path, removes caf.md, and callers can no longer use the stable caf slug. Please persist or otherwise recover explicit slug metadata so reload/rewrite preserves overrides while still distinguishing files that genuinely need legacy migration.

if not topic_path.exists():
raise FileNotFoundError(f"No memory topic named '{topic}' was found for this owner.")
return MemoryTopicRecord.from_markdown(topic_path.read_text(encoding="utf-8"), fallback_topic=topic)
for topic_path in self._topic_read_candidates(session, source_id=source_id, topic=topic):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) there is still an upgrade-path alias when the old legacy stem is also the requested new topic name. If an existing pre-digest file is topics/caf.md headed # café, then write_memory(topic="caf", ...) treats that primary path as the caf record before checking stored-topic identity, appends the fact to the café record, and migrates it instead of creating a distinct caf topic. Please handle a primary-path hit whose stored topic does not match the requested topic when that file is an unmigrated legacy file, for example by migrating the legacy record first and then writing the requested topic separately.

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.

Done in 5898505. _merge_memory now calls the new MemoryStore.migrate_misfiled_legacy_topic hook before resolving the record: when the primary path holds an unmigrated pre-digest file whose stored topic differs from the requested one (your caf.md/café case), that record is migrated to its derived name first and the requested topic is then written as a separate record.

I kept this strictness in the write path only. Reads by name-or-slug stay loose on purpose: the index keys unmigrated files by their on-disk stem, so get_topic("caf") resolving the legacy caf.md is the contract the index and provider rely on. As a sibling guard, delete_topic now skips a misfiled legacy file instead of deleting the other topic's data through this name, raising FileNotFoundError when nothing owned by the requested topic exists.

Regressions: writing caf against a legacy caf.md holding café creates a distinct caf record and preserves café under its derived name, and deleting caf in that layout raises FileNotFoundError with the café file untouched.

# The readable slug is a lossy projection (non-ASCII, accents, case,
# punctuation): two distinct topics can share it. Suffix a digest of the
# normalized topic so byte-distinct topics get distinct files.
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) the suffix uses only the first 8 hex chars of SHA-256, leaving 32 bits of collision resistance, so distinct non-ASCII topics can still share a file. For example, 丮仑一一 and 仺价一一 both have an empty readable slug and SHA-256 prefix 36749dfe, so both map to memory-topic-36749dfe.md; the second write would merge or clobber the first. Please use the full SHA-256 digest or an injective/longer storage-key encoding.

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.

Done in 5898505. _slugify_topic now suffixes the full 64-hex-char SHA-256 of the normalized topic instead of the 8-char prefix, so your 丮仑一一/仺价一一 pair (both memory-topic-36749dfe before) lands in distinct files. Verified your example locally: the prefixes do collide at 36749dfe, the full digests differ.

The regression test pins the prefix collision and asserts the two slugs are now distinct full-digest names. Safe stems stay byte-identical and idempotent as before.

@jpalvarezl

Copy link
Copy Markdown
Member

Thank you for the contribution. This pull request has had several outstanding correctness findings around memory-topic filename collisions, filename limits, and custom-slug restoration for more than three days without recent progress, so we are closing it for now to keep the review queue current. Please reopen it when you are ready to continue and address the outstanding items, and we will be happy to take another look.

This branch was successfully deployed

1 active deployment
github-app-auth — 5898505b Deployed Oct 16, 2026 by he-yufeng via add_label #25605
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: Distinct non-English memory topics silently share a file

5 participants