fix(dream): ground memory audit records in the real git diff (#4673)
* fix(dream): ground commit messages and cursor advance in the real git diff Dream consolidation could emit a /dream-log audit record that did not match the actual file changes: build_dream_commit_message appended the LLM's unverified resp.content, dream_run_completed only checked the stop reason, and file contents were deliberately omitted from the prompt. The combination let a single-turn self-report become the durable audit record. - gitstore: add summarize_working_tree() — a structured, machine-derived summary (per-file +N/-M, totals, capped unified diff) of working-tree changes vs HEAD. Pure filesystem/git ground truth, never LLM narrative. - memory: build_dream_commit_message now takes the diff body instead of resp; dream_content_diff() exposes the real delta over SOUL/USER/MEMORY.md only (excludes .dream_cursor so cursor writes aren't mistaken for edits); build_dream_prompt embeds current file contents so the model edits reality, not a stale mental model. - builtin/cli: both Dream paths now compute the diff, gate cursor advance on a non-empty delta (no-op runs no longer swallow history), and commit with the diff-grounded message. Non-git workspaces fall back to the completion check. - dream.md: document that contents are embedded, and add a chain-of- verification guardrail so the model's summary cannot claim unmade edits. A regression test proves a lying resp.content never reaches the audit log while the real diff does. * fix(dream): mark non-UTF-8 memory files as binary in diff summary Address review feedback (Q1 on PR #4673): summarize_working_tree read working-tree files with errors="replace", which would emit U+FFFD replacement chars into the audit record if a memory file ever held invalid UTF-8 — misrepresenting the diff it is meant to make truthful. Switch to errors="strict" and catch UnicodeDecodeError: a non-UTF-8 (or binary/corrupt) file is now recorded as "{path}: binary or non-UTF-8 file changed" and omitted from the unified diff, so the audit record stays honest. An empty diff block is also suppressed when all changes are binary. Adds a defensive regression test asserting no replacement char leaks.
This commit is contained in:
+97
-27
@@ -61,6 +61,29 @@ 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."""
|
||||
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
|
||||
|
||||
def test_workspace_dream_prompt_overrides_default(self, store):
|
||||
store.dream_prompt_file.parent.mkdir(parents=True)
|
||||
store.dream_prompt_file.write_text(
|
||||
@@ -566,48 +589,95 @@ class TestEphemeralHooks:
|
||||
spy.before_iteration.assert_called()
|
||||
|
||||
class TestDreamCommitMessage:
|
||||
async def test_commit_includes_response_summary(self, tmp_path):
|
||||
"""Git auto-commit after Dream should include the LLM response in the body."""
|
||||
import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
def test_commit_message_reflects_real_diff_not_narrative(self, tmp_path):
|
||||
"""The Dream commit message must mirror the real git diff and ignore the
|
||||
LLM's narrative, so ``/dream-log`` can never lie.
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
Regression for the hallucinated-commit bug: commit ``a72ca2a`` claimed a
|
||||
"Medical Research" section that never reached the diff.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
store.write_soul("# Soul")
|
||||
store.write_memory("# Memory")
|
||||
store.append_history("user discussed project goals")
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.supports_tools = True
|
||||
provider.generation = MagicMock(max_tokens=4096)
|
||||
provider.chat_with_retry = AsyncMock(return_value=MagicMock(
|
||||
content="Identified 2 new facts about project goals",
|
||||
finish_reason="stop",
|
||||
tool_calls=[],
|
||||
usage={},
|
||||
))
|
||||
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial state")
|
||||
|
||||
# Simulate what the cron handler does: produce a resp with content,
|
||||
# build the commit message via the actual function, then commit.
|
||||
resp_content = "Identified 2 new facts about project goals"
|
||||
resp = MagicMock(content=resp_content)
|
||||
# A real edit to a tracked content file.
|
||||
store.write_memory("# Memory\n- DMSO research notes")
|
||||
|
||||
# A lying narrative the old code would have appended verbatim.
|
||||
lying = "Added a Medical Research section (Mastic Gum, DMSO) to MEMORY.md"
|
||||
diff_body = store.dream_content_diff()
|
||||
assert diff_body, "real edit must be detected"
|
||||
assert "DMSO research notes" in diff_body
|
||||
assert lying not in diff_body
|
||||
|
||||
msg = MemoryStore.build_dream_commit_message(
|
||||
"dream: periodic memory consolidation", resp,
|
||||
"dream: periodic memory consolidation", diff_body,
|
||||
)
|
||||
assert lying not in msg
|
||||
assert "DMSO research notes" in msg
|
||||
|
||||
# Write a change so auto_commit has something to commit
|
||||
store.write_memory("# Memory\n- Updated by Dream")
|
||||
sha = store.git.auto_commit(msg)
|
||||
assert sha is not None
|
||||
|
||||
log = subprocess.check_output(
|
||||
["git", "log", "-1", "--format=%B"],
|
||||
cwd=str(tmp_path), text=True,
|
||||
).strip()
|
||||
assert "dream: periodic memory consolidation" in log
|
||||
assert "Identified 2 new facts" in log
|
||||
assert "DMSO research notes" in log
|
||||
assert lying not in log
|
||||
|
||||
def test_commit_message_is_bare_prefix_when_no_changes(self, tmp_path):
|
||||
"""A no-op Dream run yields only the prefix — never a narrated summary."""
|
||||
store = MemoryStore(tmp_path)
|
||||
store.write_soul("# Soul")
|
||||
store.write_memory("# Memory")
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial state")
|
||||
|
||||
# No edits at all.
|
||||
assert store.dream_content_diff() == ""
|
||||
msg = MemoryStore.build_dream_commit_message(
|
||||
"dream: manual run", store.dream_content_diff(),
|
||||
)
|
||||
assert msg == "dream: manual run"
|
||||
|
||||
def test_build_commit_message_ignores_none_and_empty_body(self):
|
||||
assert MemoryStore.build_dream_commit_message("dream: x", "") == "dream: x"
|
||||
assert MemoryStore.build_dream_commit_message("dream: x", None) == "dream: x"
|
||||
assert MemoryStore.build_dream_commit_message("dream: x", " ") == "dream: x"
|
||||
assert (
|
||||
MemoryStore.build_dream_commit_message("dream: x", "SOUL.md: +1 -0")
|
||||
== "dream: x\n\nSOUL.md: +1 -0"
|
||||
)
|
||||
|
||||
|
||||
class TestDreamContentDiff:
|
||||
"""The ground-truth signal that gates cursor advance and commit messages."""
|
||||
|
||||
def test_empty_when_git_not_initialized(self, store):
|
||||
assert store.dream_content_diff() == ""
|
||||
|
||||
def test_empty_when_no_tracked_changes(self, store):
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial")
|
||||
assert store.dream_content_diff() == ""
|
||||
|
||||
def test_reflects_real_content_edits(self, store):
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial")
|
||||
store.write_memory("# Memory\n- DMSO research notes")
|
||||
diff = store.dream_content_diff()
|
||||
assert diff
|
||||
assert "memory/MEMORY.md" in diff
|
||||
assert "DMSO research notes" in diff
|
||||
|
||||
def test_ignores_cursor_only_changes(self, store):
|
||||
"""Advancing the cursor must not count as a productive content edit."""
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial")
|
||||
store.set_last_dream_cursor(99) # only memory/.dream_cursor changes
|
||||
assert store.dream_content_diff() == ""
|
||||
|
||||
Reference in New Issue
Block a user