diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 779a24a27..e23a4c286 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -62,10 +62,6 @@ class MemoryStore: # Deliberately excludes memory/.dream_cursor so progress bookkeeping never # appears as a durable-memory edit in the audit record. _DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md") - # Per-file cap when embedding current contents into the Dream prompt. The - # durable files are tiny in practice (~5 KB total), but a runaway file must - # not unbounded the prompt. - _DREAM_FILE_EMBED_CAP = 8000 _LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*") _LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*") _LEGACY_RAW_MESSAGE_RE = re.compile( @@ -549,9 +545,7 @@ class MemoryStore: Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process. The current contents of the durable memory files (SOUL.md, USER.md, - memory/MEMORY.md) are embedded so the model edits the real files rather - than a stale mental model — eliminating a class of failed/out-of-bounds - edits that previously produced hallucinated audit records. + memory/MEMORY.md) reach Dream through the normal agent system context. """ last_cursor = self.get_last_dream_cursor() entries = self.read_unprocessed_history(since_cursor=last_cursor) @@ -564,35 +558,9 @@ class MemoryStore: for e in batch ) template = self._dream_template() - files_section = self._render_current_memory_files() - prompt = ( - f"{template}\n\n{files_section}\n\n" - f"## Conversation History\n{history_text}" - ) + prompt = f"{template}\n\n## Conversation History\n{history_text}" return (prompt, batch[-1]["cursor"]) - def _render_current_memory_files(self) -> str: - """Render the durable memory files' current contents for the Dream prompt. - - Missing files render as ``(empty)``; oversized files are capped. The - section is the ground truth the model must edit against. - """ - files = [ - ("SOUL.md", self.soul_file), - ("USER.md", self.user_file), - ("memory/MEMORY.md", self.memory_file), - ] - blocks: list[str] = [] - for label, path in files: - try: - content = path.read_text(encoding="utf-8") if path.exists() else "" - except OSError: - content = "" - if len(content) > self._DREAM_FILE_EMBED_CAP: - content = truncate_text(content, self._DREAM_FILE_EMBED_CAP) + "\n...[truncated]" - blocks.append(f"### {label}\n{content}" if content.strip() else f"### {label}\n(empty)") - return "## Current Memory Files\n" + "\n\n".join(blocks) - def dream_content_diff(self) -> str: """Structured summary of uncommitted changes to the durable memory files. diff --git a/nanobot/templates/agent/dream.md b/nanobot/templates/agent/dream.md index 37a3822c7..341bb0e72 100644 --- a/nanobot/templates/agent/dream.md +++ b/nanobot/templates/agent/dream.md @@ -99,7 +99,7 @@ For [SKILL] entries: - Skills are instruction sets with concrete values, commands, and examples. MEMORY.md keeps strategic context and high-level facts only. ## Editing -- Current contents of SOUL.md, USER.md, and memory/MEMORY.md are embedded in this prompt under "Current Memory Files". Edit those files directly; do not rely on a remembered version of a file. +- Current contents of SOUL.md, USER.md, and memory/MEMORY.md are provided by the agent system context. Edit those files directly; do not rely on a remembered version of a file. - Batch changes into as few calls as possible. Surgical edits only. ## Verification diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 4c1378184..15f1080ee 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -62,28 +62,14 @@ class TestBuildDreamPrompt: prompt, _ = result assert "skill-creator" in prompt - def test_prompt_embeds_current_memory_file_contents(self, store): - """Dream must see the real current file contents (Tier 4) so it edits the - files, not a stale mental model.""" + def test_prompt_does_not_duplicate_current_memory_file_contents(self, store): store.append_history("hello") result = store.build_dream_prompt() assert result is not None prompt, _ = result - assert "## Current Memory Files" in prompt - assert "### SOUL.md" in prompt - assert "### USER.md" in prompt - assert "### memory/MEMORY.md" in prompt - # Real current contents are embedded verbatim. - assert "Project X active" in prompt - assert "Helpful" in prompt - - def test_prompt_renders_missing_files_as_empty(self, tmp_path): - store = MemoryStore(tmp_path) # no durable files written - store.append_history("hello") - result = store.build_dream_prompt() - assert result is not None - prompt, _ = result - assert "(empty)" in prompt + assert "## Current Memory Files" not in prompt + assert "Project X active" not in prompt + assert "Helpful" not in prompt def test_workspace_dream_prompt_overrides_default(self, store): store.dream_prompt_file.parent.mkdir(parents=True) @@ -625,6 +611,63 @@ class TestEphemeralDirect: assert "entry-21" not in request_text assert "entry-60" not in request_text + async def test_dream_turn_injects_memory_files_once_and_persists_session(self, tmp_path): + """Dream gets durable files from system context without losing its session record.""" + from unittest.mock import MagicMock + + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + markers = { + "SOUL.md": "DREAM_SOUL_MARKER", + "USER.md": "DREAM_USER_MARKER", + "memory/MEMORY.md": "DREAM_MEMORY_MARKER", + } + store = MemoryStore(tmp_path) + store.write_soul(markers["SOUL.md"]) + store.write_user(markers["USER.md"]) + store.write_memory(markers["memory/MEMORY.md"]) + store.append_history("history-marker") + (tmp_path / "AGENTS.md").write_text("DREAM_AGENTS_MARKER", encoding="utf-8") + + result = store.build_dream_prompt() + assert result is not None + prompt, _ = result + + captured: dict[str, list[dict]] = {} + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.supports_tools = True + provider.generation = MagicMock(max_tokens=4096) + + async def chat_with_retry(**kwargs): + captured["messages"] = kwargs["messages"] + return LLMResponse(content="done", finish_reason="stop") + + provider.chat_with_retry = chat_with_retry + loop = AgentLoop( + bus=MessageBus(), + provider=provider, + workspace=tmp_path, + context_window_tokens=32_000, + ) + session_key = "dream:single-memory-copy" + + await loop.process_direct( + prompt, + session_key=session_key, + ephemeral=True, + tools=store.build_dream_tools(), + ) + + messages = captured["messages"] + system_prompt = str(messages[0]["content"]) + request_text = "\n".join(str(message.get("content", "")) for message in messages) + for marker in [*markers.values(), "DREAM_AGENTS_MARKER"]: + assert marker in system_prompt + assert request_text.count(marker) == 1 + assert loop.sessions._get_session_path(session_key).exists() + class TestEphemeralHooks: """When ephemeral=True, extra hooks must not fire."""