mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
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:
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user