Skip to content
Draft
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
19 changes: 14 additions & 5 deletions python/packages/core/agent_framework/_harness/_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,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()])

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 only normalizes memories as they are newly constructed. Existing pre-fix files with a continuation line (for example, - Deploy staging. followed by Then deploy production.) still lose the continuation because from_markdown ignores non-bullet lines before MemoryTopicRecord normalization runs. Please make from_markdown fold non-empty continuation lines into the preceding memory and add a regression loading raw legacy Markdown from disk.

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 51fe190. from_markdown now folds a non-empty bare line in the memories section back into the bullet it continues, so a pre-normalization file like - Deploy staging. followed by Then deploy production. loads as one memory instead of dropping the tail before MemoryTopicRecord normalization ever runs. Heading-looking lines still cannot be swallowed into a memory. Added test_memory_topic_record_folds_legacy_continuation_lines (raw legacy markdown in, folded memory out, plus a write-read round-trip); it fails against the previous parser and passes now. Full test_harness_memory.py green (36 passed), ruff check and format clean.

self.summary = _coerce_summary(summary, self.memories)
self.updated_at = updated_at
self.session_ids = _dedupe_strings(session_ids or [])
Expand Down Expand Up @@ -513,10 +516,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 '# <topic>' heading.")
Expand Down
69 changes: 69 additions & 0 deletions python/packages/core/tests/core/test_harness_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,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",
[
Expand Down Expand Up @@ -189,6 +202,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(
Expand Down Expand Up @@ -959,6 +991,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")
Expand Down
Loading