From c2cd4d3dea49bce992acb6d3f00232b48da526fd Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:16:55 +0800 Subject: [PATCH 1/2] Python: fix(core): stop losing multiline consolidated memories on reload The write path normalizes every memory through _normalize_memory_text before it reaches a record, but _consolidate_topic hands the LLM's memories strings to MemoryTopicRecord raw. A consolidated memory with a line break is written by to_markdown as a bullet whose continuation lands on a non-bullet line, and from_markdown only reads "- " bullets, so a fresh store silently returns just the first line while the raw file still shows the rest. MemoryTopicRecord now normalizes each memory at construction, keeping the existing drop-empty tolerance, so single-line memories are a record invariant and the markdown round-trip is stable no matter which path supplied the text. Verified with the full agent-framework-core suite: 8536 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 | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 6695775626..3563304c2d 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -382,7 +382,10 @@ def __init__( normalized_topic = _normalize_topic(topic) self.topic = normalized_topic self.slug = _slugify_topic(slug or normalized_topic) - self.memories = _dedupe_strings(memories) + # Collapse line breaks up front: the markdown format stores one bullet per line, + # and the consolidation path hands LLM-supplied strings in raw, so a memory kept + # multiline here would write fine but lose its continuation on the next read. + self.memories = _dedupe_strings([_normalize_memory_text(memory) for memory in memories if memory.strip()]) self.summary = _coerce_summary(summary, self.memories) self.updated_at = updated_at self.session_ids = _dedupe_strings(session_ids or []) diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index 400c9fc614..7f968d079a 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -134,6 +134,19 @@ def test_memory_topic_record_round_trips_through_dict_and_markdown() -> None: assert "MemoryTopicRecord(" in repr(record) +def test_memory_topic_record_normalizes_multiline_memories() -> None: + """Record memories collapse line breaks so the markdown round-trip stays stable.""" + record = MemoryTopicRecord( + topic="deployment", + summary="Deployment", + memories=["Deploy staging.\nThen deploy production.", " \n "], + updated_at="2026-04-21T10:05:00+00:00", + ) + + assert record.memories == ["Deploy staging. Then deploy production."] + assert MemoryTopicRecord.from_markdown(record.to_markdown()) == record + + @pytest.mark.parametrize( "summary", [ @@ -960,6 +973,43 @@ async def test_memory_consolidation_transient_failure_preserves_state(tmp_path) assert surviving.summary == "Prefers concise answers." +async def test_memory_consolidation_round_trips_multiline_memories(tmp_path) -> None: + """A consolidated memory containing line breaks must survive a fresh-store reload.""" + session = AgentSession(session_id="session-1") + session.state["owner_id"] = "alice" + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + multiline_client = _MemoryHarnessClient( + consolidation_payload={ + "summary": "Deployment", + "memories": ["Deploy staging.\nThen deploy production.", "Roll back\r\nif health checks fail."], + } + ) + provider = MemoryContextProvider(store=store, consolidation_client=multiline_client) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] + store.write_topic( + session, + MemoryTopicRecord( + topic="deployment", + summary="Deployment", + memories=["Deploy staging."], + updated_at="2026-04-21T10:00:00+00:00", + session_ids=["session-1"], + ), + source_id=DEFAULT_MEMORY_SOURCE_ID, + ) + + consolidated_count = await provider._run_consolidation( # pyright: ignore[reportPrivateUsage] + client=multiline_client, # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] + session=session, + force=True, + now=datetime(2026, 4, 22, tzinfo=timezone.utc), + ) + + assert consolidated_count == 1 + fresh_store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + reloaded = fresh_store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="deployment") + assert reloaded.memories == ["Deploy staging. Then deploy production.", "Roll back if health checks fail."] + + async def test_memory_extraction_propagates_programmer_errors(tmp_path) -> None: """Non-transient errors from the chat client must surface so misconfigurations fail loudly.""" session = AgentSession(session_id="session-1") From 51fe190b9eb33266699c1394b5ed40c23719bb60 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:13:00 +0800 Subject: [PATCH 2/2] Python: fix(core): fold legacy continuation lines in from_markdown The constructor normalization only covers memories constructed after the fix; a pre-normalization file still loses its continuation on load because the memories section ignores bare lines. Fold a non-empty bare line back into the bullet it continues and cover the legacy shape, round-trip included. --- .../core/agent_framework/_harness/_memory.py | 14 ++++++++++---- .../core/tests/core/test_harness_memory.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 3563304c2d..9b1dd5daab 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -530,10 +530,16 @@ def from_markdown(cls, markdown: str, *, fallback_topic: str | None = None) -> M if stripped: summary_lines.append(_unescape_markdown_line(stripped)) 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:]: - memories.append(memory_text) + if current_section == "memories": + if stripped.startswith("- "): + memory_text = _unescape_markdown_line(stripped[2:].strip()) + if memory_text and memory_text != DEFAULT_MEMORY_NO_TOPICS_TEXT[2:]: + memories.append(memory_text) + elif stripped and memories and not stripped.startswith("#"): + # Files written before multiline normalization carry a + # continuation line as bare text; fold it back into the + # bullet it continues instead of dropping it. + memories[-1] = f"{memories[-1]} {_unescape_markdown_line(stripped)}" if topic is None: raise ValueError("Memory topic markdown is missing a '# ' heading.") diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index 7f968d079a..447e06bd49 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -201,6 +201,25 @@ def test_memory_topic_record_preserves_multiline_summary_metadata_text(summary_l assert record.memories == ["Updated: the milestone moved.", "Sessions: retain previous notes."] +def test_memory_topic_record_folds_legacy_continuation_lines() -> None: + """A bare continuation line in a pre-normalization file rejoins the bullet it continues.""" + markdown = ( + "# deploy\n\n" + "Updated: 2026-04-21T10:05:00+00:00\n" + "Sessions: -\n\n" + "## Summary\n\n" + "## Memories\n" + "- Deploy staging.\n" + "Then deploy production.\n" + "- Rollback plan ready.\n" + ) + + record = MemoryTopicRecord.from_markdown(markdown) + + assert record.memories == ["Deploy staging. Then deploy production.", "Rollback plan ready."] + assert MemoryTopicRecord.from_markdown(record.to_markdown()).memories == record.memories + + async def test_memory_file_store_writes_topics_index_state_and_transcripts(tmp_path) -> None: """The file-backed memory store should manage topics, ``MEMORY.md``, state, and transcript search.""" store = MemoryFileStore(