mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
refactor(memory): keep session summaries structured
This commit is contained in:
@@ -421,7 +421,7 @@ class TestAutoCompact:
|
||||
|
||||
entry = loop.auto_compact._summaries.get("cli:test")
|
||||
assert entry is not None
|
||||
assert entry[0] == "User said hello."
|
||||
assert entry.text == "User said hello."
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == 12
|
||||
assert len(session_after.get_history(max_messages=12)) == (
|
||||
@@ -909,7 +909,7 @@ class TestProactiveAutoCompact:
|
||||
assert len(archived_messages) == 10
|
||||
entry = loop.auto_compact._summaries.get("cli:test")
|
||||
assert entry is not None
|
||||
assert entry[0] == "User chatted about old things."
|
||||
assert entry.text == "User chatted about old things."
|
||||
await loop.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1227,8 +1227,8 @@ class TestSummaryPersistence:
|
||||
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
|
||||
assert summary is not None
|
||||
assert "User said hello." in summary
|
||||
assert "Previous conversation summary" in summary
|
||||
assert summary.text == "User said hello."
|
||||
assert "Previous conversation summary" in summary.for_prompt()
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.aclose()
|
||||
@@ -1256,7 +1256,7 @@ class TestSummaryPersistence:
|
||||
assert summary is not None
|
||||
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary2 is not None
|
||||
assert "Summary." in summary2
|
||||
assert summary2.text == "Summary."
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.aclose()
|
||||
@@ -1306,7 +1306,7 @@ class TestSummaryPersistence:
|
||||
loop.sessions.get_or_create("cli:test"), "cli:test"
|
||||
)
|
||||
assert summary1 is not None
|
||||
assert "First summary." in summary1
|
||||
assert summary1.text == "First summary."
|
||||
assert "cli:test" not in loop.auto_compact._summaries # popped by hot path
|
||||
|
||||
# Add new messages and archive again (simulating a later turn)
|
||||
@@ -1326,7 +1326,7 @@ class TestSummaryPersistence:
|
||||
reloaded = loop.sessions.get_or_create("cli:test")
|
||||
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary2 is not None
|
||||
assert "Second summary." in summary2
|
||||
assert summary2.text == "Second summary."
|
||||
await loop.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.summary import SessionSummary
|
||||
|
||||
|
||||
def _runtime(_session: Session | None = None):
|
||||
@@ -176,29 +177,29 @@ class TestIsExpired:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_summary
|
||||
# SessionSummary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatSummary:
|
||||
"""Test AutoCompact._format_summary static method."""
|
||||
class TestSessionSummary:
|
||||
"""Test prompt rendering for the structured summary value."""
|
||||
|
||||
def test_contains_isoformat_timestamp(self):
|
||||
"""Output should contain last_active as isoformat."""
|
||||
last_active = datetime(2026, 5, 13, 14, 30, 0)
|
||||
result = AutoCompact._format_summary("Some text", last_active)
|
||||
result = SessionSummary("Some text", last_active).for_prompt()
|
||||
assert "2026-05-13T14:30:00" in result
|
||||
|
||||
def test_contains_summary_text(self):
|
||||
"""Output should contain the provided text verbatim."""
|
||||
last_active = datetime(2026, 1, 1)
|
||||
result = AutoCompact._format_summary("User discussed Python.", last_active)
|
||||
result = SessionSummary("User discussed Python.", last_active).for_prompt()
|
||||
assert "User discussed Python." in result
|
||||
|
||||
def test_output_starts_with_label(self):
|
||||
"""Output should start with the standard prefix."""
|
||||
last_active = datetime(2026, 1, 1)
|
||||
result = AutoCompact._format_summary("text", last_active)
|
||||
result = SessionSummary("text", last_active).for_prompt()
|
||||
assert result.startswith("Previous conversation summary (last active ")
|
||||
|
||||
|
||||
@@ -498,7 +499,7 @@ class TestArchiveDelegates:
|
||||
|
||||
entry = ac._summaries.get("cli:test")
|
||||
assert entry is not None
|
||||
assert entry[0] == "Hello."
|
||||
assert entry.text == "Hello."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_summary_when_compact_returns_empty(self):
|
||||
@@ -577,21 +578,21 @@ class TestPrepareSession:
|
||||
ac = _make_autocompact()
|
||||
session = _make_session()
|
||||
last_active = datetime(2026, 5, 13, 14, 0, 0)
|
||||
ac._summaries["cli:test"] = ("Hot summary.", last_active)
|
||||
ac._summaries["cli:test"] = SessionSummary("Hot summary.", last_active)
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Hot summary." in summary
|
||||
assert "Previous conversation summary" in summary
|
||||
assert summary.text == "Hot summary."
|
||||
assert "Previous conversation summary" in summary.for_prompt()
|
||||
|
||||
def test_hot_path_pops_summary_one_shot(self):
|
||||
"""Hot path should pop the summary (one-shot; second call returns None)."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session()
|
||||
last_active = datetime(2026, 1, 1)
|
||||
ac._summaries["cli:test"] = ("One-shot.", last_active)
|
||||
ac._summaries["cli:test"] = SessionSummary("One-shot.", last_active)
|
||||
|
||||
_, summary1 = ac.prepare_session(session, "cli:test")
|
||||
assert summary1 is not None
|
||||
@@ -614,7 +615,7 @@ class TestPrepareSession:
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
assert summary.text == "Cold summary."
|
||||
|
||||
def test_cold_path_tolerates_malformed_last_active(self):
|
||||
"""A malformed persisted last_active must not raise on the turn path.
|
||||
@@ -637,8 +638,8 @@ class TestPrepareSession:
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
assert fallback.isoformat() in summary
|
||||
assert summary.text == "Cold summary."
|
||||
assert summary.last_active == fallback
|
||||
|
||||
def test_cold_path_tolerates_missing_last_active(self):
|
||||
"""A _last_summary dict without last_active must not raise."""
|
||||
@@ -653,8 +654,8 @@ class TestPrepareSession:
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
assert fallback.isoformat() in summary
|
||||
assert summary.text == "Cold summary."
|
||||
assert summary.last_active == fallback
|
||||
|
||||
def test_cold_path_missing_text_returns_none(self):
|
||||
"""A _last_summary without a non-empty string text yields no summary."""
|
||||
@@ -685,7 +686,10 @@ class TestPrepareSession:
|
||||
ac.sessions = mock_sm
|
||||
key = "dream:20260602-155256"
|
||||
ac._archiving.add(key)
|
||||
ac._summaries[key] = ("Hot summary.", datetime(2026, 6, 2, 15, 52, 56))
|
||||
ac._summaries[key] = SessionSummary(
|
||||
"Hot summary.",
|
||||
datetime(2026, 6, 2, 15, 52, 56),
|
||||
)
|
||||
session = _make_session(
|
||||
key=key,
|
||||
updated_at=datetime.now() - timedelta(minutes=20),
|
||||
@@ -725,8 +729,9 @@ class TestPrepareSession:
|
||||
},
|
||||
})
|
||||
last_active = datetime(2026, 5, 13, 14, 0, 0)
|
||||
ac._summaries["cli:test"] = ("Hot summary.", last_active)
|
||||
ac._summaries["cli:test"] = SessionSummary("Hot summary.", last_active)
|
||||
|
||||
_, summary = ac.prepare_session(session, "cli:test")
|
||||
assert "Hot summary." in summary
|
||||
assert summary is not None
|
||||
assert summary.text == "Hot summary."
|
||||
# After hot path pops, cold path would kick in on next call
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Tests for ContextBuilder — system prompt and message assembly."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.runtime_context import RuntimeContextBlock
|
||||
from nanobot.session.summary import SessionSummary
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -330,14 +332,24 @@ class TestBuildSystemPrompt:
|
||||
|
||||
def test_includes_session_summary(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt(session_summary="Previous chat about Python.")
|
||||
result = builder.build_system_prompt(
|
||||
session_summary=SessionSummary(
|
||||
text="Previous chat about Python.",
|
||||
last_active=datetime(2026, 8, 19, 10, 0),
|
||||
)
|
||||
)
|
||||
assert "Previous chat about Python." in result
|
||||
assert "[Archived Context Summary]" in result
|
||||
|
||||
def test_sections_separated_by_separator(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Rules.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt(session_summary="Summary.")
|
||||
result = builder.build_system_prompt(
|
||||
session_summary=SessionSummary(
|
||||
text="Summary.",
|
||||
last_active=datetime(2026, 8, 19, 10, 0),
|
||||
)
|
||||
)
|
||||
assert "\n\n---\n\n" in result
|
||||
|
||||
def test_no_bootstrap_no_summary(self, tmp_path):
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.runtime_context import RuntimeContextBlock
|
||||
from nanobot.session.summary import SessionSummary
|
||||
|
||||
|
||||
class _FakeDatetime(real_datetime):
|
||||
@@ -120,7 +121,10 @@ def test_session_summary_replaces_matching_recent_history_entry(tmp_path) -> Non
|
||||
|
||||
builder.memory.append_history("another session event", session_key=session_key)
|
||||
summary_cursor = builder.memory.append_history(overview, session_key=session_key)
|
||||
summary = f"Previous conversation summary (last active 2026-08-19T10:00:00):\n{overview}"
|
||||
summary = SessionSummary(
|
||||
text=overview,
|
||||
last_active=real_datetime(2026, 8, 19, 10, 0),
|
||||
)
|
||||
|
||||
prompt = builder.build_system_prompt(
|
||||
session_key=session_key,
|
||||
|
||||
@@ -7,6 +7,7 @@ from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.manager import replay_max_messages_for_context
|
||||
from nanobot.session.summary import SessionSummary
|
||||
|
||||
|
||||
def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -> AgentLoop:
|
||||
@@ -202,7 +203,7 @@ async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path,
|
||||
|
||||
reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert pending is not None
|
||||
assert "User discussed project status." in pending
|
||||
assert pending.text == "User discussed project status."
|
||||
# _last_summary persists for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
|
||||
@@ -212,7 +213,13 @@ async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> Non
|
||||
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
loop.auto_compact.prepare_session = MagicMock(
|
||||
return_value=(session, "Previous conversation summary: earlier context")
|
||||
return_value=(
|
||||
session,
|
||||
SessionSummary(
|
||||
text="earlier context",
|
||||
last_active=session.updated_at,
|
||||
),
|
||||
)
|
||||
) # type: ignore[method-assign]
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
|
||||
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
|
||||
|
||||
Reference in New Issue
Block a user