Skip to content
Open
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
68 changes: 63 additions & 5 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 re
Expand Down Expand Up @@ -129,6 +130,22 @@ def _slugify_topic(topic: str) -> str:
return slug or "memory-topic"


def _topic_stem(topic: str) -> str:
"""Return the injective filename stem for a topic.

Distinct topics can share a readable slug: ``C ++`` and ``C #`` both slugify
to ``c``, so a slug-only stem let the second topic silently overwrite the
first topic's memories. The stem keeps the readable slug for humans and
appends a short digest of the normalized topic, so distinct normalized
topics never share a file (mirroring how ``_storage_key_segment`` keeps
byte-distinct identifiers in separate storage locations).
"""
normalized = _normalize_topic(topic)
slug = _slugify_topic(normalized)
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
return f"{slug}-{digest}"


def _timestamp(value: datetime | None = None) -> str:
current = value or datetime.now(timezone.utc)
return current.replace(microsecond=0).isoformat()
Expand Down Expand Up @@ -764,8 +781,49 @@ def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path:
return self._get_memory_root(session, source_id=source_id) / self.state_file_name

def _topic_path(self, session: AgentSession, *, source_id: str, topic: str) -> Path:
return self._get_topics_directory(session, source_id=source_id) / f"{_topic_stem(topic)}.md"

def _legacy_topic_path(self, session: AgentSession, *, source_id: str, topic: str) -> Path:
"""Filename stem used before the digest suffix (kept for reading old stores)."""
return self._get_topics_directory(session, source_id=source_id) / f"{_slugify_topic(topic)}.md"

def _existing_topic_path(self, session: AgentSession, *, source_id: str, topic: str) -> Path | None:
"""Return the topic file to read, or ``None`` when no file matches.

Lookups resolve in order: the digest stem of the given name (the exact
file ``write_topic`` uses for that topic), a legacy slug-only file from
stores written before the digest suffix, then any file whose stem starts
with the readable slug followed by a dash, which serves lookups made by
slug or by a topic that normalizes differently than its stored spelling.
When several topics share a slug, a slug-only lookup is ambiguous by
nature; the file whose recorded topic matches the lookup wins, else the
first stem in sorted order keeps the choice deterministic.
"""
topic_path = self._topic_path(session, source_id=source_id, topic=topic)
if topic_path.exists():
return topic_path
legacy_path = self._legacy_topic_path(session, source_id=source_id, topic=topic)
if legacy_path.exists():
return legacy_path
topics_dir = self._get_topics_directory(session, source_id=source_id)
if not topics_dir.is_dir():
return None
prefix = f"{_slugify_topic(topic)}-"
matches = sorted(path for path in topics_dir.glob(f"{prefix}*.md") if path != legacy_path)
if not matches:
return None
if len(matches) == 1:
return matches[0]
try:
wanted = _normalize_topic(topic)
except ValueError:
return matches[0]
for path in matches:
record = MemoryTopicRecord.from_markdown(path.read_text(encoding="utf-8"), fallback_topic=topic)
if record.topic == wanted:
return path
return matches[0]

@staticmethod
def _serialize_json(value: object, *, dumps: JsonDumps) -> str:
serialized = dumps(value)
Expand Down Expand Up @@ -818,20 +876,20 @@ 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():
topic_path = self._existing_topic_path(session, source_id=source_id, topic=topic)
if topic_path is None:
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)

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)
topic_path = self._topic_path(session, source_id=source_id, topic=record.topic)
_atomic_write_text(topic_path, f"{record.to_markdown()}\n")

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():
topic_path = self._existing_topic_path(session, source_id=source_id, topic=topic)
if topic_path is None:
raise FileNotFoundError(f"No memory topic named '{topic}' was found for this owner.")
topic_path.unlink()

Expand Down
39 changes: 39 additions & 0 deletions python/packages/core/tests/core/test_harness_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,45 @@ def test_memory_file_store_encodes_non_ascii_owner_ids(tmp_path) -> None:
assert roots[0] != roots[1]


def test_memory_file_store_topics_with_the_same_slug_do_not_collide(tmp_path) -> None:
"""Distinct topics that share a readable slug must keep separate memory files.

``C ++`` and ``C #`` both slugify to ``c``, so a slug-only filename let the
second topic's write destroy the first topic's memories.
"""
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"

first = MemoryTopicRecord(
topic="C++", summary="cpp", memories=["smart pointers"], updated_at="2026-10-26T00:00:00+00:00"
)
second = MemoryTopicRecord(
topic="C#", summary="csharp", memories=["primary constructors"], updated_at="2026-10-26T00:00:00+00:00"
)
store.write_topic(session, first, source_id=DEFAULT_MEMORY_SOURCE_ID)
store.write_topic(session, second, source_id=DEFAULT_MEMORY_SOURCE_ID)

assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="C++").memories == ["smart pointers"]
assert store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="C#").memories == ["primary constructors"]
listed = sorted(entry.topic for entry in store.list_topics(session, source_id=DEFAULT_MEMORY_SOURCE_ID))
assert listed == ["C#", "C++"]


def test_memory_file_store_reads_topics_from_the_legacy_slug_filename(tmp_path) -> None:
"""Topic files written before the digest suffix stay readable."""
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"

topics_dir = store._get_topics_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID)
topics_dir.mkdir(parents=True, exist_ok=True)
(topics_dir / "python.md").write_text("# python\n\n## Memories\n\n- old note\n", encoding="utf-8")

record = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="Python")
assert record.memories == ["old note"]


def test_memory_file_store_uses_literal_folders_for_safe_identifiers(tmp_path) -> None:
"""Safe identifiers are readable on disk rather than opaque encoded segments."""
store = MemoryFileStore(tmp_path, owner_prefix="user_", owner_state_key="owner_id")
Expand Down
Loading