From 61ed9bc18f6941b6e5ad90cfc851c12acac73ec4 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:26:21 +0800 Subject: [PATCH 1/5] Python: fix(core): keep distinct non-ASCII memory topics in separate files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 #9205 --- .../core/agent_framework/_harness/_memory.py | 56 ++++++++++-- .../core/tests/core/test_harness_memory.py | 88 +++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 66163abed05..7b99203997c 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import os @@ -139,6 +140,21 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non def _slugify_topic(topic: str) -> str: + normalized = _normalize_topic(topic) + slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-") + if slug == normalized.lower(): + # Already a safe stem; the mapping lost nothing, so the filename + # stays byte-identical to what older versions wrote. + return slug + # 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] + return f"{slug}-{digest}" if slug else f"memory-topic-{digest}" + + +def _legacy_slugify_topic(topic: str) -> str: + """The pre-digest slug derivation, kept to read stores written by older versions.""" slug = re.sub(r"[^a-z0-9]+", "-", _normalize_topic(topic).lower()).strip("-") return slug or "memory-topic" @@ -776,6 +792,18 @@ def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path: def _topic_path(self, session: AgentSession, *, source_id: str, topic: str) -> Path: return self._get_topics_directory(session, source_id=source_id) / f"{_slugify_topic(topic)}.md" + def _legacy_topic_path(self, session: AgentSession, *, source_id: str, topic: str) -> Path | None: + """The path a pre-digest version wrote for this topic, if it differs.""" + legacy = self._get_topics_directory(session, source_id=source_id) / f"{_legacy_slugify_topic(topic)}.md" + return legacy if legacy != self._topic_path(session, source_id=source_id, topic=topic) else None + + def _topic_read_candidates(self, session: AgentSession, *, source_id: str, topic: str) -> list[Path]: + candidates = [self._topic_path(session, source_id=source_id, topic=topic)] + legacy = self._legacy_topic_path(session, source_id=source_id, topic=topic) + if legacy is not None: + candidates.append(legacy) + return candidates + @staticmethod def _serialize_json(value: object, *, dumps: JsonDumps) -> str: serialized = dumps(value) @@ -828,22 +856,36 @@ def list_topics(self, session: AgentSession, *, source_id: str) -> list[MemoryTo def get_topic(self, session: AgentSession, *, source_id: str, topic: str) -> MemoryTopicRecord: """Return one topic memory file by topic name or slug.""" - topic_path = self._topic_path(session, source_id=source_id, topic=topic) - 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): + if topic_path.exists(): + return MemoryTopicRecord.from_markdown(topic_path.read_text(encoding="utf-8"), fallback_topic=topic) + raise FileNotFoundError(f"No memory topic named '{topic}' was found for this owner.") def write_topic(self, session: AgentSession, record: MemoryTopicRecord, *, source_id: str) -> None: """Persist one topic memory file.""" topic_path = self._topic_path(session, source_id=source_id, topic=record.slug) _atomic_write_text(topic_path, f"{record.to_markdown()}\n") + # Absorb pre-digest files into the new name so the topic does not + # list twice. The record's slug is already the derived stem, so the + # legacy name has to be re-derived from the human-readable topic too. + legacy_paths = { + path + for value in (record.topic, record.slug) + 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(): + legacy_path.unlink() def delete_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None: """Delete one topic memory file.""" - topic_path = self._topic_path(session, source_id=source_id, topic=topic) - if not topic_path.exists(): + candidates = [ + path for path in self._topic_read_candidates(session, source_id=source_id, topic=topic) if path.exists() + ] + if not candidates: raise FileNotFoundError(f"No memory topic named '{topic}' was found for this owner.") - topic_path.unlink() + for topic_path in candidates: + topic_path.unlink() def rebuild_index( self, diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index d1c7b3dac51..fda58bbd593 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -1005,4 +1005,92 @@ def test_memory_file_store_uses_literal_folders_for_safe_identifiers(tmp_path) - assert parts[1] == "user_alice" +def _topic_store(tmp_path): + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + session = AgentSession(session_id="session-1") + session.state["owner_id"] = "alice" + return store, session + + +def _record(topic: str, memories: list[str]) -> MemoryTopicRecord: + return MemoryTopicRecord( + topic=topic, + summary="", + memories=memories, + updated_at=datetime(2026, 10, 9, tzinfo=timezone.utc).isoformat(), + ) + + +def test_memory_file_store_keeps_distinct_non_ascii_topics_separate(tmp_path) -> None: + """Topics whose slugs collapse to the same stem must not share a file.""" + store, session = _topic_store(tmp_path) + travel = _record("旅行计划", ["护照已续签"]) + food = _record("饮食偏好", ["不吃香菜"]) + + store.write_topic(session, travel, source_id=DEFAULT_MEMORY_SOURCE_ID) + store.write_topic(session, food, source_id=DEFAULT_MEMORY_SOURCE_ID) + + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="旅行计划").memories == ["护照已续签"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="饮食偏好").memories == ["不吃香菜"] + + store.delete_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="饮食偏好") + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="旅行计划").memories == ["护照已续签"] + with pytest.raises(FileNotFoundError): + store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="饮食偏好") + + +def test_memory_file_store_keeps_accent_folded_topics_separate(tmp_path) -> None: + """Accents the slug strips still distinguish topics, as does case folding.""" + store, session = _topic_store(tmp_path) + store.write_topic(session, _record("café", ["a"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + store.write_topic(session, _record("cafè", ["b"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + store.write_topic(session, _record("My Topic", ["c"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["a"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="cafè").memories == ["b"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="My Topic").memories == ["c"] + + +def test_memory_file_store_reads_and_absorbs_pre_digest_topic_files(tmp_path) -> None: + """Files written before the digest suffix stay readable and migrate on rewrite.""" + store, session = _topic_store(tmp_path) + record = _record("café", ["legacy fact"]) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "caf.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{record.to_markdown()}\n", encoding="utf-8") + + loaded = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café") + assert loaded.memories == ["legacy fact"] + + store.write_topic(session, _record("café", ["new fact"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + assert not legacy_path.exists() + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["new fact"] + + +def test_memory_file_store_deletes_pre_digest_topic_file(tmp_path) -> None: + """Deleting a topic removes its legacy-named file as well.""" + store, session = _topic_store(tmp_path) + record = _record("旅行计划", ["legacy fact"]) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "memory-topic.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{record.to_markdown()}\n", encoding="utf-8") + + store.delete_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="旅行计划") + assert not legacy_path.exists() + with pytest.raises(FileNotFoundError): + store.delete_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="旅行计划") + + +def test_slugify_topic_keeps_safe_stems_byte_identical() -> None: + """Safe lowercase stems keep their pre-digest names; derived slugs are idempotent.""" + from agent_framework._harness._memory import _slugify_topic + + assert _slugify_topic("my-topic") == "my-topic" + spaced = _slugify_topic("travel plans") + assert spaced.startswith("travel-plans-") and spaced != "travel-plans" + derived = _slugify_topic("café") + assert _slugify_topic(derived) == derived + assert _slugify_topic("旅行计划") != _slugify_topic("饮食偏好") + + # endregion From 864a5b5cc75c54aaa7afd0fe2f00915c6e780cd8 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:58:16 +0800 Subject: [PATCH 2/5] Python: fix(core): verify legacy memory-topic fallback identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../core/agent_framework/_harness/_memory.py | 19 +++++++++- .../core/tests/core/test_harness_memory.py | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 7b99203997c..2741471ee92 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -800,10 +800,25 @@ def _legacy_topic_path(self, session: AgentSession, *, source_id: str, topic: st def _topic_read_candidates(self, session: AgentSession, *, source_id: str, topic: str) -> list[Path]: candidates = [self._topic_path(session, source_id=source_id, topic=topic)] legacy = self._legacy_topic_path(session, source_id=source_id, topic=topic) - if legacy is not None: + if legacy is not None and self._topic_file_matches(legacy, topic=topic): candidates.append(legacy) return candidates + @staticmethod + def _topic_file_matches(path: Path, *, topic: str) -> bool: + """True when the file's stored topic is the requested topic. + + A legacy-named file is only a fallback alias for ``topic`` when the + topic recorded inside the file agrees. The slug strips accents and + non-ASCII text, so a same-stem file can belong to a different topic + and must not be read, migrated, or deleted through the fallback. + """ + try: + record = MemoryTopicRecord.from_markdown(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, ValueError): + return False + return record.topic == _normalize_topic(topic) + @staticmethod def _serialize_json(value: object, *, dumps: JsonDumps) -> str: serialized = dumps(value) @@ -874,7 +889,7 @@ def write_topic(self, session: AgentSession, record: MemoryTopicRecord, *, sourc 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(): + if legacy_path.exists() and self._topic_file_matches(legacy_path, topic=record.topic): legacy_path.unlink() def delete_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None: diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index fda58bbd593..a1598073d7e 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -1093,4 +1093,42 @@ def test_slugify_topic_keeps_safe_stems_byte_identical() -> None: assert _slugify_topic("旅行计划") != _slugify_topic("饮食偏好") +def test_memory_file_store_delete_keeps_unrelated_stem_file(tmp_path) -> None: + """A legacy-named file owned by another topic must survive deleting this one.""" + store, session = _topic_store(tmp_path) + store.write_topic(session, _record("café", ["accent fact"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + store.write_topic(session, _record("caf", ["ascii fact"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + + store.delete_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café") + + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="caf").memories == ["ascii fact"] + with pytest.raises(FileNotFoundError): + store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café") + + +def test_memory_file_store_write_keeps_unrelated_stem_file(tmp_path) -> None: + """Writing an accented topic must not absorb the ASCII topic's own file.""" + store, session = _topic_store(tmp_path) + store.write_topic(session, _record("caf", ["ascii fact"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + store.write_topic(session, _record("café", ["accent fact"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="caf").memories == ["ascii fact"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["accent fact"] + + +def test_memory_file_store_legacy_fallback_requires_matching_stored_topic(tmp_path) -> None: + """A legacy-named file is a fallback alias only when its stored topic agrees.""" + store, session = _topic_store(tmp_path) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "caf.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{_record('café', ['legacy accent fact']).to_markdown()}\n", encoding="utf-8") + + with pytest.raises(FileNotFoundError): + store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="cafè") + store.write_topic(session, _record("cafè", ["grave accent fact"]), source_id=DEFAULT_MEMORY_SOURCE_ID) + assert legacy_path.exists() + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="cafè").memories == ["grave accent fact"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["legacy accent fact"] + + # endregion From 1ba70dbd4afd82d878eb9f1eed652520c8619c6b Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:49:13 +0800 Subject: [PATCH 3/5] Python: fix(core): resolve unmigrated legacy memory topics in the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../core/agent_framework/_harness/_memory.py | 5 +++ .../core/tests/core/test_harness_memory.py | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 2741471ee92..2745edb7d6c 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -866,6 +866,11 @@ def list_topics(self, session: AgentSession, *, source_id: str) -> list[MemoryTo topic_path.read_text(encoding="utf-8"), fallback_topic=topic_path.stem.replace("-", " "), ) + if topic_path.stem == _legacy_slugify_topic(record.topic) and topic_path.stem != record.slug: + # Not yet migrated: the file still sits at its pre-digest name. Key the + # record by the stem that exists on disk so index pointers and + # get_topic(entry.slug) resolve to it before a rewrite migrates it. + record.slug = topic_path.stem topics.append(record) return sorted(topics, key=lambda record: (record.topic.lower(), record.updated_at)) diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index a1598073d7e..221d8f77ec0 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -1131,4 +1131,47 @@ def test_memory_file_store_legacy_fallback_requires_matching_stored_topic(tmp_pa assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["legacy accent fact"] +def test_memory_file_store_lists_pre_digest_topic_under_its_on_disk_stem(tmp_path) -> None: + """An unmigrated legacy file must be listed under the stem that exists on disk.""" + store, session = _topic_store(tmp_path) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "caf.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{_record('café', ['legacy accent fact']).to_markdown()}\n", encoding="utf-8") + + (entry,) = store.list_topics(session, source_id=DEFAULT_MEMORY_SOURCE_ID) + + assert entry.topic == "café" + assert entry.slug == "caf" + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic=entry.slug).memories == [ + "legacy accent fact" + ] + store.rebuild_index(session, source_id=DEFAULT_MEMORY_SOURCE_ID, line_limit=50, line_length=200) + index_text = store._get_index_path(session, source_id=DEFAULT_MEMORY_SOURCE_ID).read_text(encoding="utf-8") + assert "topics/caf.md" in index_text + # Keying by the on-disk stem must not widen the fallback: a same-stem but + # different topic still does not alias this file. + with pytest.raises(FileNotFoundError): + store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="cafè") + + +async def test_memory_context_provider_loads_unmigrated_legacy_topic(tmp_path) -> None: + """``before_run`` must resolve an index entry whose file still uses its pre-digest name.""" + store, session = _topic_store(tmp_path) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "caf.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{_record('café', ['legacy accent fact']).to_markdown()}\n", encoding="utf-8") + provider = MemoryContextProvider(store=store, recent_turns=0, max_extractions=0) + + from agent_framework._sessions import SessionContext + + context = SessionContext( + session_id=session.session_id, + input_messages=[Message(role="user", contents=["tell me the legacy accent fact"])], + ) + + await provider.before_run(agent=None, session=session, context=context, state={}) + + assert any("legacy accent fact" in message.text for message in context.get_messages()) + + # endregion From 7579c982e40f61ae22ad9f540ea369620d426dc3 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:35 +0800 Subject: [PATCH 4/5] Python: fix(core): keep explicit slug overrides out of legacy cleanup 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. --- .../packages/core/agent_framework/_harness/_memory.py | 3 +++ .../packages/core/tests/core/test_harness_memory.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 2745edb7d6c..46f204999ab 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -888,10 +888,13 @@ def write_topic(self, session: AgentSession, record: MemoryTopicRecord, *, sourc # Absorb pre-digest files into the new name so the topic does not # list twice. The record's slug is already the derived stem, so the # legacy name has to be re-derived from the human-readable topic too. + # Never touch the file just written: an explicit slug can collide + # with the legacy stem re-derived from the topic. legacy_paths = { path for value in (record.topic, record.slug) if (path := self._legacy_topic_path(session, source_id=source_id, topic=value)) is not None + and path != topic_path } for legacy_path in legacy_paths: if legacy_path.exists() and self._topic_file_matches(legacy_path, topic=record.topic): diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index 221d8f77ec0..bf3ee70c714 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -1116,6 +1116,17 @@ def test_memory_file_store_write_keeps_unrelated_stem_file(tmp_path) -> None: assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["accent fact"] +def test_memory_file_store_write_with_explicit_slug_survives_legacy_cleanup(tmp_path) -> None: + """An explicit slug colliding with the legacy stem must not delete the file just written.""" + store, session = _topic_store(tmp_path) + record = _record("café", ["accent fact"]) + record.slug = "caf" + + store.write_topic(session, record, source_id=DEFAULT_MEMORY_SOURCE_ID) + + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="caf").memories == ["accent fact"] + + def test_memory_file_store_legacy_fallback_requires_matching_stored_topic(tmp_path) -> None: """A legacy-named file is a fallback alias only when its stored topic agrees.""" store, session = _topic_store(tmp_path) From 5898505bdcf36f44299fa9c56858fe0965f2507e Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:20:46 +0800 Subject: [PATCH 5/5] Python: fix(core): migrate same-stem legacy memory files instead of merging into them --- .../core/agent_framework/_harness/_memory.py | 69 +++++++++++++++++-- .../core/tests/core/test_harness_memory.py | 52 ++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 46f204999ab..2e7d8322258 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -147,9 +147,11 @@ def _slugify_topic(topic: str) -> str: # stays byte-identical to what older versions wrote. return slug # 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] + # punctuation): two distinct topics can share it. Suffix the full digest + # of the normalized topic so byte-distinct topics get distinct files; a + # truncated prefix still collides (e.g. "丮仑一一" and "仺价一一" share + # the 8-hex-char prefix 36749dfe). + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() return f"{slug}-{digest}" if slug else f"memory-topic-{digest}" @@ -621,6 +623,14 @@ def write_topic(self, session: AgentSession, record: MemoryTopicRecord, *, sourc def delete_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None: """Delete one topic memory file.""" + def migrate_misfiled_legacy_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None: + """Move a different topic's legacy-addressed record off this topic's canonical address. + + Only stores with lossy filename-derived addressing can hold misfiled + legacy records; the default store contract has nothing to migrate. + """ + del session, source_id, topic + @abstractmethod def rebuild_index( self, @@ -819,6 +829,21 @@ def _topic_file_matches(path: Path, *, topic: str) -> bool: return False return record.topic == _normalize_topic(topic) + @staticmethod + def _is_unmigrated_legacy_of_other_topic(path: Path, record: MemoryTopicRecord, *, topic: str) -> bool: + """True when ``path`` is a pre-digest file that belongs to a different topic. + + The slug projection is lossy, so a legacy stem like ``caf`` can hold a + file whose stored topic is ``café`` while ``caf`` is requested. Such a + file must be migrated to its own derived name first, never read, + merged, or deleted as the requested topic. + """ + return ( + record.topic != _normalize_topic(topic) + and path.stem == _legacy_slugify_topic(record.topic) + and path.stem != record.slug + ) + @staticmethod def _serialize_json(value: object, *, dumps: JsonDumps) -> str: serialized = dumps(value) @@ -902,14 +927,41 @@ def write_topic(self, session: AgentSession, record: MemoryTopicRecord, *, sourc def delete_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None: """Delete one topic memory file.""" - candidates = [ - path for path in self._topic_read_candidates(session, source_id=source_id, topic=topic) if path.exists() - ] + candidates: list[Path] = [] + for topic_path in self._topic_read_candidates(session, source_id=source_id, topic=topic): + if not topic_path.exists(): + continue + try: + record = MemoryTopicRecord.from_markdown(topic_path.read_text(encoding="utf-8"), fallback_topic=topic) + except (OSError, UnicodeDecodeError, ValueError): + record = None + if record is not None and self._is_unmigrated_legacy_of_other_topic(topic_path, record, topic=topic): + # A different topic's pre-digest file sits at this stem: it is + # not this topic's file, so it must survive the delete. + continue + candidates.append(topic_path) if not candidates: raise FileNotFoundError(f"No memory topic named '{topic}' was found for this owner.") for topic_path in candidates: topic_path.unlink() + def migrate_misfiled_legacy_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None: + """Move a different topic's pre-digest file off this topic's primary path. + + The slug projection is lossy, so a legacy stem like ``caf`` can hold a + file whose stored topic is ``café``. A write for ``caf`` must migrate + that record to its derived name first instead of merging into it. + """ + topic_path = self._topic_path(session, source_id=source_id, topic=topic) + if not topic_path.exists(): + return + try: + record = MemoryTopicRecord.from_markdown(topic_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, ValueError): + return + if self._is_unmigrated_legacy_of_other_topic(topic_path, record, topic=topic): + self.write_topic(session, record, source_id=source_id) + def rebuild_index( self, session: AgentSession, @@ -1590,6 +1642,11 @@ async def _merge_memory( normalized_topic = _normalize_topic(topic) normalized_memory = _normalize_memory_text(memory) async with self._topic_async_lock(session, normalized_topic): + # get_topic resolves loosely (slugs and legacy aliases included): a + # pre-digest file of another topic can sit at this topic's primary + # path. Migrate such a record first so this write never merges into + # or overwrites a different topic. + self.store.migrate_misfiled_legacy_topic(session, source_id=self.source_id, topic=normalized_topic) try: existing_record = self.store.get_topic(session, source_id=self.source_id, topic=normalized_topic) except FileNotFoundError: diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index bf3ee70c714..40945d09319 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -1081,6 +1081,58 @@ def test_memory_file_store_deletes_pre_digest_topic_file(tmp_path) -> None: store.delete_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="旅行计划") +async def test_memory_context_provider_write_migrates_misfiled_legacy_first(tmp_path) -> None: + """``write_memory("caf")`` must not merge into a legacy ``caf.md`` that holds ``café``.""" + store, session = _topic_store(tmp_path) + record = _record("café", ["legacy accent fact"]) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "caf.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{record.to_markdown()}\n", encoding="utf-8") + provider = MemoryContextProvider(store=store, recent_turns=0, max_extractions=0) + + updated = await provider._merge_memory( + session=session, + topic="caf", + memory="ascii fact", + now=datetime(2026, 10, 16, tzinfo=timezone.utc), + ) + + # The write created a distinct `caf` record instead of appending to `café`. + assert updated.topic == "caf" + assert updated.memories == ["ascii fact"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="caf").memories == ["ascii fact"] + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["legacy accent fact"] + + +def test_memory_file_store_delete_keeps_misfiled_legacy_file(tmp_path) -> None: + """Deleting ``caf`` must not delete a legacy ``caf.md`` that belongs to ``café``.""" + store, session = _topic_store(tmp_path) + record = _record("café", ["legacy accent fact"]) + legacy_path = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID) / "caf.md" + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text(f"{record.to_markdown()}\n", encoding="utf-8") + + with pytest.raises(FileNotFoundError): + store.delete_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="caf") + assert legacy_path.exists() + assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="café").memories == ["legacy accent fact"] + + +def test_slugify_topic_uses_full_sha256_digest() -> None: + """Truncated digest prefixes collide; the storage key keeps the full SHA-256.""" + import hashlib + + from agent_framework._harness._memory import _slugify_topic + + first, second = "丮仑一一", "仺价一一" + # Both topics share the former 8-hex-char prefix 36749dfe. + assert hashlib.sha256(first.encode("utf-8")).hexdigest()[:8] == hashlib.sha256(second.encode("utf-8")).hexdigest()[:8] + slug_first, slug_second = _slugify_topic(first), _slugify_topic(second) + assert slug_first != slug_second + assert slug_first == f"memory-topic-{hashlib.sha256(first.encode('utf-8')).hexdigest()}" + assert slug_second == f"memory-topic-{hashlib.sha256(second.encode('utf-8')).hexdigest()}" + + def test_slugify_topic_keeps_safe_stems_byte_identical() -> None: """Safe lowercase stems keep their pre-digest names; derived slugs are idempotent.""" from agent_framework._harness._memory import _slugify_topic