mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-23 18:12:32 +00:00
Three improvements to Dream's memory consolidation: 1. Per-line git-blame age annotations: MEMORY.md lines get `← Nd` suffixes (N>14) from dulwich annotate. SOUL.md/USER.md excluded as permanent. LLM uses content judgment, not just age, to decide what to prune. 2. Dedup-aware Phase 1 prompt: reframed as dual-task (extract facts + deduplicate existing files) with explicit redundancy patterns to scan for. Validated through 20 experiments (exp-002 prompt + max_iter=15 was best, averaging -1643 chars/5.4% compression per run). 3. Phase 1 analysis as commit body: dream git commits now include the full Phase 1 analysis for transparency via /dream-log. 4. max_iterations raised from 10 to 15: 30% improvement over 10 with no risk; 20 showed diminishing returns (exp-020: -701 vs exp-017: -1643).
178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
|
|
|
import pytest
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from nanobot.agent.memory import Dream, MemoryStore
|
|
from nanobot.agent.runner import AgentRunResult
|
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
s = MemoryStore(tmp_path)
|
|
s.write_soul("# Soul\n- Helpful")
|
|
s.write_user("# User\n- Developer")
|
|
s.write_memory("# Memory\n- Project X active")
|
|
return s
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_provider():
|
|
p = MagicMock()
|
|
p.chat_with_retry = AsyncMock()
|
|
return p
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_runner():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def dream(store, mock_provider, mock_runner):
|
|
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
|
|
d._runner = mock_runner
|
|
return d
|
|
|
|
|
|
def _make_run_result(
|
|
stop_reason="completed",
|
|
final_content=None,
|
|
tool_events=None,
|
|
usage=None,
|
|
):
|
|
return AgentRunResult(
|
|
final_content=final_content or stop_reason,
|
|
stop_reason=stop_reason,
|
|
messages=[],
|
|
tools_used=[],
|
|
usage={},
|
|
tool_events=tool_events or [],
|
|
)
|
|
|
|
|
|
class TestDreamRun:
|
|
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
|
|
"""Dream should not call LLM when there's nothing to process."""
|
|
result = await dream.run()
|
|
assert result is False
|
|
mock_provider.chat_with_retry.assert_not_called()
|
|
mock_runner.run.assert_not_called()
|
|
|
|
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
|
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
|
store.append_history("User prefers dark mode")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result(
|
|
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
|
|
))
|
|
result = await dream.run()
|
|
assert result is True
|
|
mock_runner.run.assert_called_once()
|
|
spec = mock_runner.run.call_args[0][0]
|
|
assert spec.max_iterations == 10
|
|
assert spec.fail_on_tool_error is False
|
|
|
|
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
|
|
"""Dream should advance the cursor after processing."""
|
|
store.append_history("event 1")
|
|
store.append_history("event 2")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
await dream.run()
|
|
assert store.get_last_dream_cursor() == 2
|
|
|
|
async def test_compacts_processed_history(self, dream, mock_provider, mock_runner, store):
|
|
"""Dream should compact history after processing."""
|
|
store.append_history("event 1")
|
|
store.append_history("event 2")
|
|
store.append_history("event 3")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
await dream.run()
|
|
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
|
entries = store.read_unprocessed_history(since_cursor=0)
|
|
assert all(e["cursor"] > 0 for e in entries)
|
|
|
|
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
|
|
"""Dream should point skill creation guidance at the builtin skill-creator template."""
|
|
store.append_history("Repeated workflow one")
|
|
store.append_history("Repeated workflow two")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
|
|
await dream.run()
|
|
|
|
spec = mock_runner.run.call_args[0][0]
|
|
system_prompt = spec.initial_messages[0]["content"]
|
|
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
|
assert expected in system_prompt
|
|
|
|
async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
|
|
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
|
|
write_tool = dream._tools.get("write_file")
|
|
assert write_tool is not None
|
|
|
|
result = await write_tool.execute(
|
|
path="skills/test-skill/SKILL.md",
|
|
content="---\nname: test-skill\ndescription: Test\n---\n",
|
|
)
|
|
|
|
assert "Successfully wrote" in result
|
|
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
|
|
|
|
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
|
|
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
|
|
store.append_history("some event")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
|
|
# Init git so line_ages works
|
|
store.git.init()
|
|
store.git.auto_commit("initial memory state")
|
|
|
|
await dream.run()
|
|
|
|
# The MEMORY.md section should not crash and should contain the memory content
|
|
call_args = mock_provider.chat_with_retry.call_args
|
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
|
assert "## Current MEMORY.md" in user_msg
|
|
|
|
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
|
|
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
|
|
store.append_history("some event")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
|
|
store.git.init()
|
|
store.git.auto_commit("initial state")
|
|
|
|
await dream.run()
|
|
|
|
call_args = mock_provider.chat_with_retry.call_args
|
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
|
# The ← suffix should only appear in MEMORY.md section
|
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
|
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
|
|
user_section = user_msg.split("## Current USER.md")[1]
|
|
# SOUL and USER should not contain age arrows
|
|
assert "\u2190" not in soul_section
|
|
assert "\u2190" not in user_section
|
|
|
|
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
|
|
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
|
|
store.append_history("some event")
|
|
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
|
|
await dream.run()
|
|
|
|
# Should still succeed — just without age annotations
|
|
mock_provider.chat_with_retry.assert_called_once()
|
|
call_args = mock_provider.chat_with_retry.call_args
|
|
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
|
assert "## Current MEMORY.md" in user_msg
|
|
|