Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 128 additions & 6 deletions python/packages/core/agent_framework/_harness/_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -139,6 +140,23 @@ 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():

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.

# 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 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}"

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.

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



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"

Expand Down Expand Up @@ -605,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,
Expand Down Expand Up @@ -776,6 +802,48 @@ 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 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 _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)
Expand Down Expand Up @@ -823,27 +891,76 @@ 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))

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):

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.

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.

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.
# 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):

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.

legacy_path.unlink()

def delete_topic(self, session: AgentSession, *, source_id: str, topic: str) -> None:
"""Delete one topic memory file."""
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():
raise FileNotFoundError(f"No memory topic named '{topic}' was found for this owner.")
topic_path.unlink()
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,
Expand Down Expand Up @@ -1525,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:
Expand Down
Loading
Loading