refactor(dream): replace two-phase Dream class with simple cron + process_direct (#3990)
* refactor(dream): replace two-phase Dream class with simple cron + process_direct - Remove the heavyweight Dream class (AgentRunner-based two-phase system) from nanobot/agent/memory.py - Delete dream_phase1.md and dream_phase2.md templates - New dream.md template serves as the consolidation prompt - Cron callback uses agent.process_direct(prompt, session_key=\"dream\") instead of agent.dream.run() - Always performs git auto_commit after execution - /dream command updated to use process_direct + git commit - DreamConfig kept for backward compatibility; deprecated fields (model_override, max_batch_size, max_iterations, annotate_line_ages) are ignored but accepted in config - interval_h remains configurable via agents.defaults.dream.interval_h - Update tests and webui settings to match new architecture * feat(loop): add ephemeral mode to process_direct, skip history writes for Dream When ephemeral=True, _state_save skips enforce_file_cap (which calls raw_archive -> append_history) and consolidator.maybe_consolidate_by_tokens. This prevents Dream sessions from creating a positive feedback loop where they process their own output. The session IS still saved to disk. * fix(loop): skip extra hooks for ephemeral sessions (Dream) * feat(dream): per-run timestamped sessions with rotation for WebUI * test(config): restore DreamConfig schedule and alias tests * fix(dream): include LLM response summary in git auto-commit message The old two-phase Dream class included the Phase 1 analysis in the git commit message body. The new single-phase version lost this. Restore it by extracting resp.content from the process_direct return value and appending it to the commit message in both the cron handler and the /dream command. * fix(test): accept ephemeral kwarg in test_openai_api fake_process * refactor(dream): merge dream_session.py into MemoryStore The standalone dream_session.py module only contained three small helpers that all revolve around MemoryStore concerns (session keys, commit messages, file pruning). Fold them into MemoryStore as @staticmethod to reduce indirection and avoid a 35-line module with no independent reason to exist. * fix(test): address code review — patch correct instance, use actual function - Fix test_ephemeral_skips_raw_archive to patch loop.context.memory instead of the fixture's separate MemoryStore instance - Fix TestDreamCommitMessage to call MemoryStore.build_dream_commit_message instead of reimplementing the logic inline - Move Dream helpers in memory.py above the Consolidator section comment to avoid misleading visual boundary * fix(dream): gate cursor advancement and restrict tools maintainer edit: Dream now processes backlog from the oldest unprocessed entries, only advances the cursor after a completed ephemeral run, and uses a restricted file-only tool registry for background consolidation. * fix(dream): skip idle compact for dream sessions Dream runs use internal dream:* sessions that are pruned by Dream retention. Exclude them from AutoCompact scheduling, archive execution, and summary injection so idle-session compaction cannot truncate Dream transcripts. * fix(dream): keep batched history isolated * feat(dream): tag archived memory for single-phase Dream --------- Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
@@ -203,9 +203,15 @@ class TestCheckExpired:
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
|
||||
scheduled = []
|
||||
|
||||
def scheduler(coro):
|
||||
scheduled.append(coro)
|
||||
coro.close()
|
||||
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_called_once()
|
||||
assert len(scheduled) == 1
|
||||
assert "cli:old" in ac._archiving
|
||||
|
||||
def test_active_session_key_skips(self):
|
||||
@@ -251,6 +257,22 @@ class TestCheckExpired:
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_not_called()
|
||||
|
||||
def test_dream_session_skips(self):
|
||||
"""Internal Dream sessions should not be scheduled for idle compact."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [
|
||||
{"key": "dream:20260602-155256", "updated_at": old_ts},
|
||||
]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
|
||||
ac.check_expired(scheduler)
|
||||
|
||||
scheduler.assert_not_called()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive
|
||||
@@ -273,6 +295,17 @@ class TestArchiveDelegates:
|
||||
"cli:test", ac._RECENT_SUFFIX_MESSAGES,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_session_is_ignored(self):
|
||||
ac = _make_autocompact()
|
||||
ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
|
||||
ac._archiving.add("dream:20260602-155256")
|
||||
|
||||
await ac._archive("dream:20260602-155256")
|
||||
|
||||
ac.consolidator.compact_idle_session.assert_not_awaited()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populates_summaries_from_metadata(self):
|
||||
ac = _make_autocompact()
|
||||
@@ -416,6 +449,33 @@ class TestPrepareSession:
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
|
||||
def test_dream_session_skips_reload_and_summaries(self):
|
||||
"""Internal Dream sessions should not reload or receive compact summaries."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
ac.sessions = mock_sm
|
||||
key = "dream:20260602-155256"
|
||||
ac._archiving.add(key)
|
||||
ac._summaries[key] = ("Hot summary.", datetime(2026, 6, 2, 15, 52, 56))
|
||||
session = _make_session(
|
||||
key=key,
|
||||
updated_at=datetime.now() - timedelta(minutes=20),
|
||||
metadata={
|
||||
"_last_summary": {
|
||||
"text": "Cold summary.",
|
||||
"last_active": "2026-06-02T15:52:56",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result_session, summary = ac.prepare_session(session, key)
|
||||
|
||||
mock_sm.get_or_create.assert_not_called()
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
assert key not in ac._archiving
|
||||
assert key not in ac._summaries
|
||||
|
||||
def test_cold_path_metadata_not_dict_returns_none(self):
|
||||
"""If metadata _last_summary is not a dict, should return None summary."""
|
||||
ac = _make_autocompact()
|
||||
|
||||
Reference in New Issue
Block a user