mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
fix(dream): stop duplicating SOUL/USER/MEMORY into the Dream prompt (#5622)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
chengyongru
parent
eebe3ca292
commit
dda67286f9
+2
-34
@@ -62,10 +62,6 @@ class MemoryStore:
|
|||||||
# Deliberately excludes memory/.dream_cursor so progress bookkeeping never
|
# Deliberately excludes memory/.dream_cursor so progress bookkeeping never
|
||||||
# appears as a durable-memory edit in the audit record.
|
# appears as a durable-memory edit in the audit record.
|
||||||
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
|
_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_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_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
|
||||||
_LEGACY_RAW_MESSAGE_RE = re.compile(
|
_LEGACY_RAW_MESSAGE_RE = re.compile(
|
||||||
@@ -549,9 +545,7 @@ class MemoryStore:
|
|||||||
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
|
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
|
||||||
|
|
||||||
The current contents of the durable memory files (SOUL.md, USER.md,
|
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
|
memory/MEMORY.md) reach Dream through the normal agent system context.
|
||||||
than a stale mental model — eliminating a class of failed/out-of-bounds
|
|
||||||
edits that previously produced hallucinated audit records.
|
|
||||||
"""
|
"""
|
||||||
last_cursor = self.get_last_dream_cursor()
|
last_cursor = self.get_last_dream_cursor()
|
||||||
entries = self.read_unprocessed_history(since_cursor=last_cursor)
|
entries = self.read_unprocessed_history(since_cursor=last_cursor)
|
||||||
@@ -564,35 +558,9 @@ class MemoryStore:
|
|||||||
for e in batch
|
for e in batch
|
||||||
)
|
)
|
||||||
template = self._dream_template()
|
template = self._dream_template()
|
||||||
files_section = self._render_current_memory_files()
|
prompt = f"{template}\n\n## Conversation History\n{history_text}"
|
||||||
prompt = (
|
|
||||||
f"{template}\n\n{files_section}\n\n"
|
|
||||||
f"## Conversation History\n{history_text}"
|
|
||||||
)
|
|
||||||
return (prompt, batch[-1]["cursor"])
|
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:
|
def dream_content_diff(self) -> str:
|
||||||
"""Structured summary of uncommitted changes to the durable memory files.
|
"""Structured summary of uncommitted changes to the durable memory files.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- Skills are instruction sets with concrete values, commands, and examples. MEMORY.md keeps strategic context and high-level facts only.
|
||||||
|
|
||||||
## Editing
|
## 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.
|
- Batch changes into as few calls as possible. Surgical edits only.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
+61
-18
@@ -62,28 +62,14 @@ class TestBuildDreamPrompt:
|
|||||||
prompt, _ = result
|
prompt, _ = result
|
||||||
assert "skill-creator" in prompt
|
assert "skill-creator" in prompt
|
||||||
|
|
||||||
def test_prompt_embeds_current_memory_file_contents(self, store):
|
def test_prompt_does_not_duplicate_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."""
|
|
||||||
store.append_history("hello")
|
store.append_history("hello")
|
||||||
result = store.build_dream_prompt()
|
result = store.build_dream_prompt()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
prompt, _ = result
|
prompt, _ = result
|
||||||
assert "## Current Memory Files" in prompt
|
assert "## Current Memory Files" not in prompt
|
||||||
assert "### SOUL.md" in prompt
|
assert "Project X active" not in prompt
|
||||||
assert "### USER.md" in prompt
|
assert "Helpful" not 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
|
|
||||||
|
|
||||||
def test_workspace_dream_prompt_overrides_default(self, store):
|
def test_workspace_dream_prompt_overrides_default(self, store):
|
||||||
store.dream_prompt_file.parent.mkdir(parents=True)
|
store.dream_prompt_file.parent.mkdir(parents=True)
|
||||||
@@ -625,6 +611,63 @@ class TestEphemeralDirect:
|
|||||||
assert "entry-21" not in request_text
|
assert "entry-21" not in request_text
|
||||||
assert "entry-60" 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:
|
class TestEphemeralHooks:
|
||||||
"""When ephemeral=True, extra hooks must not fire."""
|
"""When ephemeral=True, extra hooks must not fire."""
|
||||||
|
|||||||
Reference in New Issue
Block a user