diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index c3f02f883b..f10c8036a9 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -45,6 +45,11 @@ DEFAULT_MEMORY_CONSOLIDATION_INTERVAL: Final[timedelta] = timedelta(hours=24) DEFAULT_MEMORY_INDEX_HEADER = "# MEMORY" DEFAULT_MEMORY_NO_TOPICS_TEXT = "- none yet" + +# Suffix marking a genuine memory whose exact text equals the empty-list +# placeholder, so a singleton real memory is distinguishable from a written +# placeholder line on read. Unmarked files keep their previous meaning. +_MEMORY_GENUINE_PLACEHOLDER_SUFFIX = " " DEFAULT_MEMORY_EXTRACTION_PROMPT = """You extract durable memory candidates from an agent transcript delta. Return only JSON with this exact shape: @@ -446,7 +451,7 @@ def to_markdown(self) -> str: # content (or LLM-supplied consolidation output) for a section delimiter. escaped_summary_lines = [_escape_markdown_line(line) for line in self.summary.splitlines()] escaped_summary = "\n".join(escaped_summary_lines) - memory_lines = [f"- {_escape_markdown_line(memory)}" for memory in self.memories] or [ + memory_lines = [f"- {_escape_markdown_line(self._encode_memory_text(memory))}" for memory in self.memories] or [ f"- {DEFAULT_MEMORY_NO_TOPICS_TEXT[2:]}" ] return "\n".join([ @@ -462,6 +467,18 @@ def to_markdown(self) -> str: *memory_lines, ]).rstrip() + @staticmethod + def _encode_memory_text(memory: str) -> str: + """Mark a genuine memory equal to the placeholder so a reader keeps it. + + The write side emits the bare placeholder line only for an empty memory + list; a real memory whose exact text equals it gets a distinguishing + suffix so a singleton round trip cannot collapse to empty. + """ + if memory == DEFAULT_MEMORY_NO_TOPICS_TEXT[2:]: + return memory + _MEMORY_GENUINE_PLACEHOLDER_SUFFIX + return memory + @classmethod def from_markdown(cls, markdown: str, *, fallback_topic: str | None = None) -> MemoryTopicRecord: """Parse one topic record from the canonical markdown format. @@ -515,9 +532,20 @@ def from_markdown(cls, markdown: str, *, fallback_topic: str | None = None) -> M continue if current_section == "memories" and stripped.startswith("- "): memory_text = _unescape_markdown_line(stripped[2:].strip()) - if memory_text and memory_text != DEFAULT_MEMORY_NO_TOPICS_TEXT[2:]: + if memory_text: memories.append(memory_text) + # Decode the genuine-marker suffix first so a marked memory can never be + # mistaken for the placeholder, then drop a bare placeholder only when it + # is the sole entry: that is the only shape the write side emits it in, + # and unmarked files keep their previous meaning. + placeholder = DEFAULT_MEMORY_NO_TOPICS_TEXT[2:] + marked = placeholder + _MEMORY_GENUINE_PLACEHOLDER_SUFFIX + was_marked = [memory == marked for memory in memories] + memories = [placeholder if is_marked else memory for memory, is_marked in zip(memories, was_marked)] + if memories == [placeholder] and not was_marked[0]: + memories = [] + if topic is None: raise ValueError("Memory topic markdown is missing a '# ' heading.") return cls( diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index 464255d0b4..42f571a06f 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -1046,6 +1046,81 @@ def test_memory_file_store_encodes_non_ascii_owner_ids(tmp_path) -> None: assert roots[0] != roots[1] +def test_memory_topic_record_keeps_a_memory_that_matches_the_placeholder(tmp_path) -> None: + """A real memory whose text equals the empty-list placeholder must survive a + round trip; only the placeholder written for an empty memory list is dropped.""" + record = MemoryTopicRecord( + topic="status", + summary="s", + memories=["none yet", "real fact"], + updated_at="2026-10-26T00:00:00+00:00", + ) + + parsed = MemoryTopicRecord.from_markdown(record.to_markdown(), fallback_topic="status") + + assert parsed.memories == ["none yet", "real fact"] + + +def test_memory_topic_record_round_trips_a_genuine_singleton_placeholder_memory() -> None: + """A real memories=[none yet] singleton must not collapse to an empty list. + + The write side marks a genuine memory whose exact text equals the + placeholder with a distinguishing suffix, and the reader decodes the mark + before applying the sole-placeholder drop, so only a bare placeholder line + written for an empty list reads back empty. + """ + record = MemoryTopicRecord( + topic="notes", + summary="s", + memories=["none yet"], + updated_at="2026-10-26T00:00:00+00:00", + ) + parsed = MemoryTopicRecord.from_markdown(record.to_markdown(), fallback_topic="notes") + assert parsed.memories == ["none yet"] + + +def test_memory_topic_record_marks_genuine_placeholder_on_write() -> None: + """The written form of a genuine placeholder-valued memory carries the + distinguishing suffix, so it cannot be confused with a written placeholder.""" + record = MemoryTopicRecord( + topic="notes", + summary="s", + memories=["none yet", "real fact"], + updated_at="2026-10-26T00:00:00+00:00", + ) + markdown = record.to_markdown() + assert "- none yet " in markdown + assert "- real fact" in markdown + + +def test_memory_topic_record_keeps_unmarked_placeholder_behavior_for_legacy_files() -> None: + """Files written before the marker keep their previous meaning: a sole bare + placeholder line still reads back as an empty memory list.""" + legacy = "\n".join([ + "# notes", + "", + "Updated: 2026-10-26T00:00:00+00:00", + "Sessions: -", + "", + "## Summary", + "s", + "", + "## Memories", + "- none yet", + ]) + parsed = MemoryTopicRecord.from_markdown(legacy, fallback_topic="notes") + assert parsed.memories == [] + + +def test_memory_topic_record_still_drops_the_placeholder_for_an_empty_list() -> None: + """An empty memory list writes the placeholder line and reads back empty.""" + record = MemoryTopicRecord(topic="empty", summary="s", memories=[], updated_at="2026-10-26T00:00:00+00:00") + + parsed = MemoryTopicRecord.from_markdown(record.to_markdown(), fallback_topic="empty") + + assert parsed.memories == [] + + 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")