refactor(agent): make memory summaries cumulative (#5610)

* refactor(agent): make memory summaries cumulative

Treat the latest session summary as a replacement checkpoint, preserve it through bounded raw fallbacks, and reserve history.jsonl for Dream ingestion.

* fix(agent): preserve cumulative checkpoint context

* fix(agent): preserve memory archive prompt cache

* refactor(agent): state archive prompt positively

* refactor(agent): remove checkpoint version migration

* refactor(agent): summarize full archive context

* test(agent): align cumulative archive prompt assertion

* refactor(agent): clarify memory checkpoint contract
This commit is contained in:
chengyongru
2026-08-31 13:37:20 +08:00
committed by GitHub
parent 6cd7063682
commit bb34b58f47
12 changed files with 476 additions and 514 deletions
+3 -3
View File
@@ -1302,9 +1302,9 @@ class TestSummaryPersistence:
assert "_last_summary" in reloaded.metadata
# Simulate /new command
session.clear()
loop.sessions.save(session)
loop.sessions.invalidate(session.key)
reloaded.clear()
loop.sessions.save(reloaded)
loop.sessions.invalidate(reloaded.key)
# After /new, metadata should no longer contain _last_summary
fresh = loop.sessions.get_or_create("cli:test")
+242 -43
View File
@@ -1,4 +1,4 @@
"""Tests for the lightweight Consolidator — append-only to HISTORY.md."""
"""Tests for Memory checkpoint consolidation and history journaling."""
from dataclasses import replace
from unittest.mock import AsyncMock, MagicMock
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.memory import (
_ARCHIVE_SUMMARY_MAX_CHARS,
_HISTORY_ENTRY_HARD_CAP,
Consolidator,
MemoryStore,
)
@@ -26,6 +26,8 @@ from nanobot.session.manager import Session
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template
_ARCHIVE_PROMPT = render_template("agent/consolidator_archive.md", strip=True)
@pytest.fixture
def store(tmp_path):
@@ -98,8 +100,15 @@ def _build_test_messages(**kwargs):
]
async def _archive(consolidator, messages, runtime, *, session_key="test:session"):
return await consolidator.archive(
async def _archive(
consolidator,
messages,
runtime,
*,
session_key="test:session",
previous_summary=None,
):
return await consolidator.archiver.archive(
messages,
runtime=runtime,
session_key=session_key,
@@ -108,6 +117,7 @@ async def _archive(consolidator, messages, runtime, *, session_key="test:session
current_message="consolidate",
),
request_tools=[],
previous_summary=previous_summary,
)
@@ -201,7 +211,9 @@ class TestConsolidatorSummarize:
mock_provider.chat_with_retry.side_effect = Exception("API error")
messages = [{"role": "user", "content": "hello"}]
result = await _archive(consolidator, messages, runtime)
assert result is None # no summary on raw dump fallback
assert result is not None
assert "[RAW]" in result
assert "hello" in result
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" in entries[0]["content"]
@@ -226,23 +238,51 @@ class TestConsolidatorSummarize:
entries = store.read_unprocessed_history(since_cursor=0)
assert entries[0]["session_key"] == "slack:chat-2"
async def test_raw_fallback_represents_previous_checkpoint_and_new_chunk(
self,
consolidator,
mock_provider,
runtime,
):
runtime = replace(runtime, generation=GenerationSettings(max_tokens=96))
mock_provider.chat_with_retry.side_effect = RuntimeError("API error")
result = await _archive(
consolidator,
[{"role": "user", "content": "NEW_MARKER " + "new " * 200}],
runtime,
previous_summary="OLD_MARKER " + "old " * 200,
)
assert result is not None
assert "[Previous archived context]" in result
assert "OLD_MARKER" in result
assert "[Newly archived raw context]" in result
assert "NEW_MARKER" in result
assert "... (truncated)" in result
async def test_summarize_skips_empty_messages(self, consolidator, runtime):
result = await _archive(consolidator, [], runtime)
assert result is None
class TestConsolidatorPromptContract:
def test_archive_prompt_preserves_working_state_with_memory_facts(self):
prompt = render_template("agent/consolidator_archive.md", strip=True, archive_count=4)
def test_archive_prompt_requests_a_cumulative_replacement_checkpoint(self):
prompt = _ARCHIVE_PROMPT
for section in ("## Merge rules", "## What to retain", "## Output"):
assert section in prompt
assert "replacement checkpoint" in prompt
assert "[Archived Context Summary]" in prompt
assert "current conversation state" in prompt
assert "SNIP" in prompt
assert "final 4 conversation messages" in prompt
for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]", "[skip]"):
for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]"):
assert mark in prompt
assert "working-state handoff" in prompt
assert "exact identifiers needed to continue without rework" in prompt
assert "Do not output facts already present in the system prompt's Recent History" in prompt
assert "Do not mark something [skip] merely because it might already exist" in prompt
assert "- [mark] fact" in prompt
assert "[skip]" not in prompt
assert "(nothing)" in prompt
assert "history.jsonl" not in prompt
class TestConsolidatorArchiveErrorHandling:
@@ -272,7 +312,8 @@ class TestConsolidatorArchiveErrorHandling:
{"role": "assistant", "content": "Done, fixed the race condition."},
]
result = await _archive(consolidator, messages, runtime)
assert result is None
assert result is not None
assert "[RAW]" in result
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" in entries[0]["content"]
@@ -436,9 +477,9 @@ class TestConsolidatorTokenBudget:
assert [message["content"] for message in request["messages"][1:-1]] == [
f"m{i}" for i in range(50)
]
assert "final 50 conversation messages" in request["messages"][-1]["content"]
assert request["messages"][-1]["content"] == _ARCHIVE_PROMPT
assert request["tools"] == []
assert request["tool_choice"] == "none"
assert "tool_choice" not in request
assert session.last_archived == 50
assert session.provider_state == _provider_state()
@@ -460,8 +501,7 @@ class TestConsolidatorTokenBudget:
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
# LLM consolidation fails after raw_archive fires.
consolidator.archive_session = AsyncMock(return_value=None)
consolidator.archive_session = AsyncMock(return_value="[RAW] checkpoint")
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
@@ -491,7 +531,7 @@ class TestConsolidatorTokenBudget:
consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(1200, "tiktoken")
)
consolidator.archive_session = AsyncMock(return_value=None)
consolidator.archive_session = AsyncMock(return_value="[RAW] checkpoint")
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
@@ -613,27 +653,62 @@ class TestCompactIdleSession:
assert reloaded.last_archived == 2
assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"]
@pytest.mark.asyncio
async def test_idle_compaction_with_no_new_messages_is_noop(
self, real_consolidator, mock_provider, store, runtime
):
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:archived-idle")
session.add_message("user", "already archived")
session.add_message("assistant", "old answer")
session.last_archived = 2
sessions.save(session)
sessions.invalidate("cli:archived-idle")
result = await real_consolidator.compact_idle_session(
"cli:archived-idle",
runtime=runtime,
)
assert result == ""
mock_provider.chat_with_retry.assert_not_awaited()
reloaded = sessions.get_or_create("cli:archived-idle")
assert reloaded.last_archived == 2
assert "_last_summary" not in reloaded.metadata
assert store.read_unprocessed_history(since_cursor=0) == []
@pytest.mark.asyncio
async def test_new_messages_advance_existing_archive_progress(
self, real_consolidator, mock_provider, runtime
):
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary.", finish_reason="stop"
)
mock_provider.chat_with_retry.side_effect = [
MagicMock(content="First replacement checkpoint.", finish_reason="stop"),
MagicMock(content="Second replacement checkpoint.", finish_reason="stop"),
]
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:incremental")
session.add_message("user", "first user")
session.add_message("assistant", "first assistant")
sessions.save(session)
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
first = await real_consolidator.compact_idle_session(
"cli:incremental",
runtime=runtime,
)
current = sessions.get_or_create("cli:incremental")
current.add_message("user", "second user")
current.add_message("assistant", "second assistant")
sessions.save(current)
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
second = await real_consolidator.compact_idle_session(
"cli:incremental",
runtime=runtime,
)
assert first == "First replacement checkpoint."
assert second == "Second replacement checkpoint."
assert mock_provider.chat_with_retry.await_count == 2
latest_build = real_consolidator.archiver._build_messages.call_args_list[-1].kwargs
assert latest_build["session_summary"]["text"] == "First replacement checkpoint."
latest_messages = mock_provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
assert [message["content"] for message in latest_messages[1:5]] == [
"first user",
@@ -641,8 +716,91 @@ class TestCompactIdleSession:
"second user",
"second assistant",
]
assert "final 2 conversation messages" in latest_messages[-1]["content"]
assert sessions.get_or_create("cli:incremental").last_archived == 4
assert latest_messages[-1]["content"] == _ARCHIVE_PROMPT
sessions.invalidate("cli:incremental")
reloaded = sessions.get_or_create("cli:incremental")
assert reloaded.last_archived == 4
assert reloaded.metadata["_last_summary"]["text"] == second
@pytest.mark.asyncio
async def test_raw_fallback_preserves_previous_checkpoint_and_new_chunk(
self,
real_consolidator,
mock_provider,
store,
runtime,
):
mock_provider.chat_with_retry.side_effect = [
LLMResponse(content="Earlier durable checkpoint.", finish_reason="stop"),
RuntimeError("LLM unavailable"),
]
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:cumulative-fallback")
session.add_message("user", "first user")
session.add_message("assistant", "first answer")
sessions.save(session)
await real_consolidator.compact_idle_session(
"cli:cumulative-fallback",
runtime=runtime,
)
current = sessions.get_or_create("cli:cumulative-fallback")
current.add_message("user", "second user")
current.add_message("assistant", "newest working state")
sessions.save(current)
fallback = await real_consolidator.compact_idle_session(
"cli:cumulative-fallback",
runtime=runtime,
)
assert fallback is not None
assert "[Previous archived context]" in fallback
assert "Earlier durable checkpoint." in fallback
assert "[Newly archived raw context]" in fallback
assert "newest working state" in fallback
entries = store.read_unprocessed_history(0)
assert entries[0]["content"] == "Earlier durable checkpoint."
assert entries[1]["content"].startswith("[RAW] 2 messages")
sessions.invalidate("cli:cumulative-fallback")
reloaded = sessions.get_or_create("cli:cumulative-fallback")
assert reloaded.metadata["_last_summary"]["text"] == fallback
@pytest.mark.asyncio
async def test_nothing_keeps_previous_replacement_checkpoint(
self,
real_consolidator,
mock_provider,
runtime,
):
mock_provider.chat_with_retry.side_effect = [
LLMResponse(content="Existing checkpoint.", finish_reason="stop"),
LLMResponse(content="(nothing)", finish_reason="stop"),
]
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:nothing-after-summary")
session.add_message("user", "important first turn")
session.add_message("assistant", "important result")
sessions.save(session)
await real_consolidator.compact_idle_session(
"cli:nothing-after-summary",
runtime=runtime,
)
current = sessions.get_or_create("cli:nothing-after-summary")
current.add_message("user", "thanks")
current.add_message("assistant", "you're welcome")
sessions.save(current)
result = await real_consolidator.compact_idle_session(
"cli:nothing-after-summary",
runtime=runtime,
)
assert result == "(nothing)"
sessions.invalidate("cli:nothing-after-summary")
reloaded = sessions.get_or_create("cli:nothing-after-summary")
assert reloaded.last_archived == 4
assert reloaded.metadata["_last_summary"]["text"] == "Existing checkpoint."
@pytest.mark.asyncio
async def test_concurrent_append_remains_unarchived(
@@ -792,11 +950,16 @@ class TestCompactIdleSession:
result = await real_consolidator.compact_idle_session(
"cli:nothing", runtime=runtime, max_suffix=4
)
second = await real_consolidator.compact_idle_session(
"cli:nothing", runtime=runtime, max_suffix=4
)
assert result == "(nothing)"
assert second == ""
reloaded = sessions.get_or_create("cli:nothing")
assert "_last_summary" not in reloaded.metadata
assert real_consolidator.store.read_unprocessed_history(0) == []
mock_provider.chat_with_retry.assert_awaited_once()
@pytest.mark.asyncio
async def test_llm_failure_preserves_history_but_advances_replay_boundary(
@@ -813,7 +976,8 @@ class TestCompactIdleSession:
result = await real_consolidator.compact_idle_session(
"cli:fail", runtime=runtime, max_suffix=4
)
assert result is None
assert result is not None
assert "[RAW]" in result
# raw_archive should have been called (history.jsonl gets an entry)
entries = store.read_unprocessed_history(since_cursor=0)
@@ -823,6 +987,7 @@ class TestCompactIdleSession:
assert len(reloaded.messages) == 20
assert reloaded.messages[0]["content"] == "u0"
assert reloaded.last_archived == 20
assert reloaded.metadata["_last_summary"]["text"] == result
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
"u6",
"a6",
@@ -863,11 +1028,10 @@ class TestCompactIdleSession:
archived_call = mock_provider.chat_with_retry.call_args
sent_messages = archived_call.kwargs["messages"]
sent_content = [message.get("content") for message in sent_messages]
# The ordinary replay prefix contributes recent context, while the
# temporary instruction limits the new overview to the unarchived tail.
# The replacement overview covers all model-visible conversation context.
assert "u0" not in sent_content
assert "u26" in sent_content
assert "final 10 conversation messages" in sent_messages[-1]["content"]
assert sent_messages[-1]["content"] == _ARCHIVE_PROMPT
@pytest.mark.asyncio
async def test_full_archive_keeps_extended_legal_replay_suffix(
@@ -956,9 +1120,9 @@ class TestCompactIdleSession:
"user",
]
assert sent_messages[2]["tool_calls"][0]["id"] == "call-1"
assert "final 4 conversation messages" in sent_messages[-1]["content"]
assert sent_messages[-1]["content"] == _ARCHIVE_PROMPT
assert call["tools"] == tools
assert call["tool_choice"] == "none"
assert "tool_choice" not in call
reloaded = sessions.get_or_create("cli:tool-history")
assert len(reloaded.messages) == 4
@@ -996,7 +1160,8 @@ class TestCompactIdleSession:
runtime=runtime,
)
assert result is None
assert result is not None
assert "[RAW]" in result
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert entries[0]["content"].startswith("[RAW] ")
@@ -1026,7 +1191,8 @@ class TestCompactIdleSession:
runtime=runtime,
)
assert result is None
assert result is not None
assert "[RAW]" in result
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert entries[0]["content"].startswith("[RAW] ")
@@ -1052,7 +1218,8 @@ class TestCompactIdleSession:
runtime=runtime,
)
assert result is None
assert result is not None
assert "[RAW]" in result
mock_provider.chat_with_retry.assert_not_awaited()
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
@@ -1060,7 +1227,7 @@ class TestCompactIdleSession:
assert sessions.get_or_create("sdk:oversized").last_archived == 1
@pytest.mark.asyncio
async def test_incremental_scope_counts_only_model_visible_messages(
async def test_archive_context_contains_only_model_visible_messages(
self,
real_consolidator,
mock_provider,
@@ -1093,7 +1260,7 @@ class TestCompactIdleSession:
"new user",
"new answer",
]
assert "final 2 conversation messages" in sent[-1]["content"]
assert sent[-1]["content"] == _ARCHIVE_PROMPT
@pytest.mark.asyncio
async def test_reuses_real_prefix_for_unified_session_workspace(
@@ -1126,8 +1293,6 @@ class TestCompactIdleSession:
current_message="next project question",
channel="websocket",
workspace=project,
session_key=session.key,
unified_session=True,
)
await loop.consolidator.compact_idle_session(
@@ -1137,7 +1302,7 @@ class TestCompactIdleSession:
sent_messages = runtime.provider.chat_with_retry.call_args.kwargs["messages"]
assert sent_messages[:-1] == ordinary_messages[:-1]
assert "final 2 conversation messages" in sent_messages[-1]["content"]
assert sent_messages[-1]["content"] == _ARCHIVE_PROMPT
system = sent_messages[0]["content"]
assert "PROJECT_WORKSPACE_MARKER" in system
assert "GLOBAL_WORKSPACE_MARKER" not in system
@@ -1307,6 +1472,21 @@ class TestRawArchiveTruncation:
assert len(entries) == 1
assert "hello" in entries[0]["content"]
def test_raw_archive_returns_the_sanitized_persisted_checkpoint(self, store):
messages = [
{
"role": "user",
"content": "<think>PRIVATE_REASONING</think>visible result",
}
]
checkpoint = store.raw_archive(messages, session_key="cli:test")
persisted = store.read_unprocessed_history(since_cursor=0)[0]["content"]
assert checkpoint == persisted
assert "PRIVATE_REASONING" not in checkpoint
assert "visible result" in checkpoint
def test_raw_archive_excludes_model_only_runtime_context(self, store):
content, marker = append_runtime_context(
"ship the feature",
@@ -1338,21 +1518,40 @@ class TestRawArchiveTruncation:
class TestArchivePersistence:
async def test_oversized_summary_is_capped_before_append(
async def test_archive_returns_the_sanitized_persisted_summary(
self, consolidator, mock_provider, store, runtime
):
mock_provider.chat_with_retry.return_value = MagicMock(
content="<think>PRIVATE_REASONING</think>safe summary",
finish_reason="stop",
has_tool_calls=False,
)
summary = await _archive(
consolidator,
[{"role": "user", "content": "hi"}],
runtime,
)
persisted = store.read_unprocessed_history(since_cursor=0)[0]["content"]
assert summary == persisted == "safe summary"
async def test_oversized_summary_uses_history_emergency_cap(
self, consolidator, mock_provider, store, runtime
):
"""A pathologically large LLM summary must not land full-length in
history.jsonl — that would re-open the #3412 bloat vector from the
*success* path instead of the fallback path."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="S" * (_ARCHIVE_SUMMARY_MAX_CHARS * 10),
content="S" * (_HISTORY_ENTRY_HARD_CAP * 2),
finish_reason="stop",
)
await _archive(
summary = await _archive(
consolidator,
[{"role": "user", "content": "hi"}],
runtime,
)
entry = store.read_unprocessed_history(since_cursor=0)[0]
assert len(entry["content"]) <= _ARCHIVE_SUMMARY_MAX_CHARS + 50
assert len(entry["content"]) <= _HISTORY_ENTRY_HARD_CAP + 50
assert summary == entry["content"]
+2 -8
View File
@@ -133,10 +133,7 @@ class TestLoadBootstrapFiles:
(project / "SOUL.md").write_text("project soul collision", encoding="utf-8")
(project / "USER.md").write_text("project user collision", encoding="utf-8")
result = ContextBuilder(agent_home).build_system_prompt(
workspace=project,
include_memory_recent_history=False,
)
result = ContextBuilder(agent_home).build_system_prompt(workspace=project)
assert "selected project rules" in result
assert "global project rules" not in result
@@ -152,10 +149,7 @@ class TestLoadBootstrapFiles:
project.mkdir()
(agent_home / "AGENTS.md").write_text("default workspace rules", encoding="utf-8")
result = ContextBuilder(agent_home).build_system_prompt(
workspace=project,
include_memory_recent_history=False,
)
result = ContextBuilder(agent_home).build_system_prompt(workspace=project)
assert "default workspace rules" not in result
-168
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import datetime as datetime_module
import re
from datetime import datetime as real_datetime
from importlib.resources import files as pkg_files
from pathlib import Path
@@ -104,173 +103,6 @@ def test_provider_context_appended_after_user_content(tmp_path) -> None:
assert user_pos < context_pos, "user content must precede provider context"
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
builder.memory.append_history("User asked about weather in Tokyo")
builder.memory.append_history("Agent fetched forecast via web_search")
prompt = builder.build_system_prompt()
assert "# Recent History" in prompt
assert "User asked about weather in Tokyo" in prompt
assert "Agent fetched forecast via web_search" in prompt
assert re.search(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]", prompt)
def test_recent_history_injection_is_session_scoped(tmp_path) -> None:
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
builder.memory.append_history("legacy entry without session")
builder.memory.append_history("telegram history", session_key="telegram:chat-1")
builder.memory.append_history("slack history", session_key="slack:chat-2")
prompt = builder.build_system_prompt(session_key="telegram:chat-1")
assert "# Recent History" in prompt
assert "telegram history" in prompt
assert "slack history" not in prompt
assert "legacy entry without session" not in prompt
def test_session_summary_replaces_interleaved_recent_history_entry(tmp_path) -> None:
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
session_key = "unified:default"
overview = "CURRENT_SESSION_OVERVIEW_MARKER"
builder.memory.append_history("another session event", session_key=session_key)
builder.memory.append_history(overview, session_key=session_key)
latest_cursor = builder.memory.append_history(
"later telegram event",
session_key="telegram:chat-1",
)
summary = {"text": overview, "last_active": "2026-08-19T10:00:00"}
prompt = builder.build_system_prompt(
session_key=session_key,
session_summary=summary,
unified_session=True,
)
assert "# Recent History" in prompt
assert "another session event" in prompt
assert "later telegram event" in prompt
assert "[Archived Context Summary]" in prompt
assert prompt.count(overview) == 1
builder.memory.set_last_dream_cursor(latest_cursor)
processed_prompt = builder.build_system_prompt(
session_key=session_key,
session_summary=summary,
unified_session=True,
)
assert "# Recent History" not in processed_prompt
assert processed_prompt.count(overview) == 1
def test_recent_history_injection_unified_excludes_cron_internals(tmp_path) -> None:
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
builder.memory.append_history("unified user history", session_key="unified:default")
builder.memory.append_history("channel user history", session_key="telegram:chat-1")
builder.memory.append_history("cron internal history", session_key="cron:job-1")
prompt = builder.build_system_prompt(
session_key="unified:default",
unified_session=True,
)
assert "unified user history" in prompt
assert "channel user history" in prompt
assert "cron internal history" not in prompt
def test_cron_recent_history_can_see_own_history_and_unified_context(tmp_path) -> None:
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
builder.memory.append_history("unified user history", session_key="unified:default")
builder.memory.append_history("own cron history", session_key="cron:job-1")
builder.memory.append_history("other cron history", session_key="cron:job-2")
prompt = builder.build_system_prompt(
session_key="cron:job-1",
unified_session=True,
)
assert "unified user history" in prompt
assert "own cron history" in prompt
assert "other cron history" not in prompt
def test_recent_history_capped_at_max(tmp_path) -> None:
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
for i in range(builder._MAX_RECENT_HISTORY + 20):
builder.memory.append_history(f"entry-{i}")
prompt = builder.build_system_prompt()
assert "entry-0" not in prompt
assert "entry-19" not in prompt
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
def test_recent_history_truncated_at_max_tokens(tmp_path) -> None:
"""Recent History section must be truncated to _MAX_HISTORY_TOKENS."""
import tiktoken
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
big_entry = "word " * (builder._MAX_HISTORY_TOKENS + 5_000)
builder.memory.append_history(big_entry)
prompt = builder.build_system_prompt()
history_section = prompt.split("# Recent History\n\n", 1)
assert len(history_section) == 2
enc = tiktoken.get_encoding("cl100k_base")
assert len(enc.encode(history_section[1])) <= builder._MAX_HISTORY_TOKENS
def test_no_recent_history_when_dream_has_processed_all(tmp_path) -> None:
"""If Dream has consumed everything, no Recent History section should appear."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
cursor = builder.memory.append_history("already processed entry")
builder.memory.set_last_dream_cursor(cursor)
prompt = builder.build_system_prompt()
assert "# Recent History" not in prompt
def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
"""When Dream has processed some entries, only the unprocessed ones appear."""
workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace)
builder.memory.append_history("old conversation about Python")
c2 = builder.memory.append_history("old conversation about Rust")
builder.memory.append_history("recent question about Docker")
builder.memory.append_history("recent question about K8s")
builder.memory.set_last_dream_cursor(c2)
prompt = builder.build_system_prompt()
assert "# Recent History" in prompt
assert "old conversation about Python" not in prompt
assert "old conversation about Rust" not in prompt
assert "recent question about Docker" in prompt
assert "recent question about K8s" in prompt
def test_execution_rules_in_system_prompt(tmp_path) -> None:
"""Execution rules should appear in the system prompt via the default templates."""
from nanobot.utils.helpers import sync_workspace_templates
+36 -2
View File
@@ -7,11 +7,17 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -> AgentLoop:
def _make_loop(
tmp_path,
*,
estimated_tokens: int,
context_window_tokens: int,
max_tokens: int = 0,
) -> AgentLoop:
from nanobot.providers.base import GenerationSettings
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings(max_tokens=0)
provider.generation = GenerationSettings(max_tokens=max_tokens)
provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
_response = LLMResponse(content="ok", tool_calls=[])
provider.chat_with_retry = AsyncMock(return_value=_response)
@@ -56,6 +62,34 @@ async def test_prompt_above_threshold_triggers_consolidation(tmp_path) -> None:
assert loop.consolidator.archive_session.await_count >= 1
@pytest.mark.asyncio
async def test_token_consolidation_refreshes_summary_for_current_request(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.consolidator.archive_session = AsyncMock( # type: ignore[method-assign]
return_value="FRESH_CHECKPOINT"
)
loop.consolidator.estimate_session_prompt_tokens = MagicMock( # type: ignore[method-assign]
return_value=(1000, "test")
)
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": role, "content": f"{role[0]}{turn}"}
for turn in range(10)
for role in ("user", "assistant")
]
loop.sessions.save(session)
await loop.process_direct("hello", session_key="cli:test")
request_messages = loop.provider.chat_with_retry.await_args.kwargs["messages"]
system_prompt = request_messages[0]["content"]
assert "FRESH_CHECKPOINT" in system_prompt
assert all(message.get("content") != "u0" for message in request_messages)
assert loop.sessions.get_or_create("cli:test").last_archived == 12
@pytest.mark.asyncio
async def test_prompt_above_threshold_uses_fixed_recent_tail(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
-48
View File
@@ -113,54 +113,6 @@ class TestHistoryWithCursor:
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 2
def test_prompt_history_filters_to_current_session(self, store):
store.append_history("legacy entry without session")
store.append_history("telegram entry", session_key="telegram:chat-1")
store.append_history("slack entry", session_key="slack:chat-2")
entries = store.read_recent_history_for_prompt(
since_cursor=0,
session_key="telegram:chat-1",
)
assert [e["content"] for e in entries] == ["telegram entry"]
assert [e["content"] for e in store.read_unprocessed_history(0)] == [
"legacy entry without session",
"telegram entry",
"slack entry",
]
def test_unified_prompt_history_excludes_internal_cron_sessions(self, store):
store.append_history("legacy entry without session")
store.append_history("unified entry", session_key="unified:default")
store.append_history("telegram entry", session_key="telegram:chat-1")
store.append_history("cron internal entry", session_key="cron:job-1")
entries = store.read_recent_history_for_prompt(
since_cursor=0,
session_key="unified:default",
unified_session=True,
)
assert [e["content"] for e in entries] == [
"legacy entry without session",
"unified entry",
"telegram entry",
]
def test_unified_cron_prompt_history_includes_own_cron_entry(self, store):
store.append_history("unified entry", session_key="unified:default")
store.append_history("other cron entry", session_key="cron:job-2")
store.append_history("own cron entry", session_key="cron:job-1")
entries = store.read_recent_history_for_prompt(
since_cursor=0,
session_key="cron:job-1",
unified_session=True,
)
assert [e["content"] for e in entries] == ["unified entry", "own cron entry"]
def test_read_unprocessed_skips_entries_without_cursor(self, store):
"""Regression: entries missing the cursor key should be silently skipped."""
store.history_file.write_text(
+5 -1
View File
@@ -8,6 +8,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.utils.prompt_templates import render_template
_ARCHIVE_PROMPT = render_template("agent/consolidator_archive.md", strip=True)
class TestNewCommandArchival:
"""Test /new archival behavior with the structured archive flow."""
@@ -117,7 +121,7 @@ class TestNewCommandArchival:
await loop.aclose()
sent = loop.provider.chat_with_retry.call_args.kwargs["messages"]
assert sent[1:-1] == ordinary_history
assert "final 2 conversation messages" in sent[-1]["content"]
assert sent[-1]["content"] == _ARCHIVE_PROMPT
@pytest.mark.asyncio
async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None: