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:
+67
-7
@@ -41,6 +41,14 @@ class MemoryStore:
|
|||||||
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
||||||
|
|
||||||
_DEFAULT_MAX_HISTORY = 1000
|
_DEFAULT_MAX_HISTORY = 1000
|
||||||
|
# Durable files whose real working-tree delta grounds Dream commit messages
|
||||||
|
# and the cursor-advance gate. Deliberately excludes memory/.dream_cursor so
|
||||||
|
# that advancing the cursor itself is never mistaken for a productive edit.
|
||||||
|
_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
|
||||||
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
|
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
|
||||||
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
|
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
|
||||||
_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*")
|
||||||
@@ -524,6 +532,11 @@ class MemoryStore:
|
|||||||
"""Build the Dream prompt with unprocessed history context.
|
"""Build the Dream prompt with unprocessed history context.
|
||||||
|
|
||||||
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,
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
@@ -536,9 +549,46 @@ class MemoryStore:
|
|||||||
for e in batch
|
for e in batch
|
||||||
)
|
)
|
||||||
template = self._dream_template()
|
template = self._dream_template()
|
||||||
prompt = f"{template}\n\n## Conversation History\n{history_text}"
|
files_section = self._render_current_memory_files()
|
||||||
|
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 = []
|
||||||
|
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.
|
||||||
|
|
||||||
|
Returns "" when git is unavailable or no content file changed. This is
|
||||||
|
the ground-truth input for diff-grounded Dream commit messages and for
|
||||||
|
gating cursor advance on real edits (never on LLM self-report).
|
||||||
|
"""
|
||||||
|
if not self._git.is_initialized():
|
||||||
|
return ""
|
||||||
|
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
|
||||||
|
|
||||||
def build_dream_tools(self):
|
def build_dream_tools(self):
|
||||||
"""Build the restricted tool registry used by Dream runs."""
|
"""Build the restricted tool registry used by Dream runs."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
@@ -630,12 +680,22 @@ class MemoryStore:
|
|||||||
return f"dream:{datetime.now():%Y%m%d-%H%M%S}"
|
return f"dream:{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_dream_commit_message(prefix: str, resp: object | None) -> str:
|
def build_dream_commit_message(prefix: str, diff_body: str) -> str:
|
||||||
"""Build a Dream auto-commit message, appending the LLM summary if present."""
|
"""Build a Dream commit message grounded in the real working-tree diff.
|
||||||
msg = prefix
|
|
||||||
if resp is not None and getattr(resp, "content", None):
|
*diff_body* is a structured, machine-derived summary of the actual file
|
||||||
msg = f"{msg}\n\n{resp.content.strip()}"
|
changes (see :meth:`dream_content_diff` /
|
||||||
return msg
|
:meth:`GitStore.summarize_working_tree`). The LLM narrative is
|
||||||
|
deliberately excluded so the audit record (``/dream-log``) reflects the
|
||||||
|
filesystem's truth, not the model's self-report.
|
||||||
|
|
||||||
|
An empty *diff_body* yields the bare *prefix*, which ``auto_commit``
|
||||||
|
turns into a no-op when there is nothing to stage.
|
||||||
|
"""
|
||||||
|
diff_body = (diff_body or "").strip()
|
||||||
|
if not diff_body:
|
||||||
|
return prefix
|
||||||
|
return f"{prefix}\n\n{diff_body}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
||||||
|
|||||||
+14
-2
@@ -1397,6 +1397,7 @@ def _run_gateway(
|
|||||||
|
|
||||||
store = agent.context.memory
|
store = agent.context.memory
|
||||||
resp = None
|
resp = None
|
||||||
|
diff_body = ""
|
||||||
try:
|
try:
|
||||||
result = store.build_dream_prompt()
|
result = store.build_dream_prompt()
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -1411,9 +1412,20 @@ def _run_gateway(
|
|||||||
tools=store.build_dream_tools(),
|
tools=store.build_dream_tools(),
|
||||||
on_progress=_silent,
|
on_progress=_silent,
|
||||||
)
|
)
|
||||||
if MemoryStore.dream_run_completed(resp):
|
# Ground truth: the real file delta, not the LLM's self-report.
|
||||||
|
diff_body = store.dream_content_diff()
|
||||||
|
productive = bool(diff_body) or (
|
||||||
|
not store.git.is_initialized()
|
||||||
|
and MemoryStore.dream_run_completed(resp)
|
||||||
|
)
|
||||||
|
if productive:
|
||||||
store.set_last_dream_cursor(last_cursor)
|
store.set_last_dream_cursor(last_cursor)
|
||||||
logger.info("Dream cron job completed, cursor advanced to {}", last_cursor)
|
logger.info("Dream cron job completed, cursor advanced to {}", last_cursor)
|
||||||
|
elif MemoryStore.dream_run_completed(resp):
|
||||||
|
logger.info(
|
||||||
|
"Dream cron job completed with no memory changes; "
|
||||||
|
"cursor not advanced",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Dream cron job did not complete; cursor remains at {}",
|
"Dream cron job did not complete; cursor remains at {}",
|
||||||
@@ -1431,7 +1443,7 @@ def _run_gateway(
|
|||||||
)
|
)
|
||||||
if store.git.is_initialized():
|
if store.git.is_initialized():
|
||||||
msg = build_dream_commit_message(
|
msg = build_dream_commit_message(
|
||||||
"dream: periodic memory consolidation", resp,
|
"dream: periodic memory consolidation", diff_body,
|
||||||
)
|
)
|
||||||
sha = store.git.auto_commit(msg)
|
sha = store.git.auto_commit(msg)
|
||||||
if sha:
|
if sha:
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
|||||||
store = loop.context.memory
|
store = loop.context.memory
|
||||||
content = ""
|
content = ""
|
||||||
resp = None
|
resp = None
|
||||||
|
diff_body = ""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
result = store.build_dream_prompt()
|
result = store.build_dream_prompt()
|
||||||
@@ -379,9 +380,17 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
|||||||
on_progress=_silent,
|
on_progress=_silent,
|
||||||
)
|
)
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
if MemoryStore.dream_run_completed(resp):
|
# Ground truth: the real file delta, not the LLM's self-report.
|
||||||
|
diff_body = store.dream_content_diff()
|
||||||
|
productive = bool(diff_body) or (
|
||||||
|
not store.git.is_initialized()
|
||||||
|
and MemoryStore.dream_run_completed(resp)
|
||||||
|
)
|
||||||
|
if productive:
|
||||||
store.set_last_dream_cursor(last_cursor)
|
store.set_last_dream_cursor(last_cursor)
|
||||||
content = f"Dream completed in {elapsed:.1f}s."
|
content = f"Dream completed in {elapsed:.1f}s."
|
||||||
|
elif MemoryStore.dream_run_completed(resp):
|
||||||
|
content = f"Dream completed in {elapsed:.1f}s; no memory changes."
|
||||||
else:
|
else:
|
||||||
content = (
|
content = (
|
||||||
f"Dream did not complete after {elapsed:.1f}s; "
|
f"Dream did not complete after {elapsed:.1f}s; "
|
||||||
@@ -399,7 +408,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
|||||||
timezone_name=getattr(loop.context, "timezone", None),
|
timezone_name=getattr(loop.context, "timezone", None),
|
||||||
)
|
)
|
||||||
if store.git.is_initialized():
|
if store.git.is_initialized():
|
||||||
commit_msg = build_dream_commit_message("dream: manual run", resp)
|
commit_msg = build_dream_commit_message("dream: manual run", diff_body)
|
||||||
sha = store.git.auto_commit(commit_msg)
|
sha = store.git.auto_commit(commit_msg)
|
||||||
if sha:
|
if sha:
|
||||||
content += f" (commit {sha})"
|
content += f" (commit {sha})"
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ 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
|
||||||
- Inspect current file contents before editing; they are not embedded in the prompt to keep context compact.
|
- 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.
|
||||||
- Batch changes into as few calls as possible. Surgical edits only.
|
- Batch changes into as few calls as possible. Surgical edits only.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
Your final summary may reference only edits confirmed by a successful tool result — that result is your proof of every change. Do not narrate edits you did not make. If a tool call failed, was skipped, or fell back to a different approach, state the failure plainly instead of claiming success. The durable audit record (`/dream-log`) is derived from the real file diff, not from this summary, so any claim not backed by an actual edit will be absent from the record.
|
||||||
|
|
||||||
Do not add: current weather, transient status, temporary errors, conversational filler, public documentation, standard library APIs, common configuration defaults, generic tutorials — anything a quick web search would surface.
|
Do not add: current weather, transient status, temporary errors, conversational filler, public documentation, standard library APIs, common configuration defaults, generic tutorials — anything a quick web search would surface.
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
# Cap on the unified-diff block embedded in Dream commit messages. Memory files
|
||||||
|
# are tiny in practice, but a pathological rewrite must not blow up the audit
|
||||||
|
# record. The structured per-file summary is always emitted in full regardless.
|
||||||
|
_WORKING_TREE_DIFF_MAX_CHARS = 6000
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommitInfo:
|
class CommitInfo:
|
||||||
@@ -299,6 +304,119 @@ class GitStore:
|
|||||||
logger.exception("Git diff_commits failed")
|
logger.exception("Git diff_commits failed")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def summarize_working_tree(self, paths: list[str]) -> str:
|
||||||
|
"""Structured summary of working-tree changes vs HEAD for *paths*.
|
||||||
|
|
||||||
|
Pure filesystem/git ground truth — never LLM narrative — suitable as a
|
||||||
|
truthful audit record. Returns "" when the repo is not initialized or
|
||||||
|
none of *paths* differ from HEAD.
|
||||||
|
|
||||||
|
Format::
|
||||||
|
|
||||||
|
SOUL.md: +3 -1
|
||||||
|
memory/MEMORY.md: +12 -8
|
||||||
|
|
||||||
|
2 files changed, 15 insertions(+), 9 deletions(-)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
--- SOUL.md
|
||||||
|
+++ SOUL.md
|
||||||
|
@@ ...
|
||||||
|
- old
|
||||||
|
+ new
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if not self.is_initialized():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
from dulwich.repo import Repo
|
||||||
|
except ImportError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
summary_lines: list[str] = []
|
||||||
|
diff_lines: list[str] = []
|
||||||
|
total_added = 0
|
||||||
|
total_removed = 0
|
||||||
|
changed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Repo(str(self._workspace)) as repo:
|
||||||
|
head_tree = self._head_tree(repo)
|
||||||
|
for path in paths:
|
||||||
|
head_text = (
|
||||||
|
self._read_blob_from_tree(repo, head_tree, path)
|
||||||
|
if head_tree is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if head_text is None:
|
||||||
|
head_text = ""
|
||||||
|
wt_path = self._workspace / path
|
||||||
|
try:
|
||||||
|
wt_text = (
|
||||||
|
wt_path.read_text(encoding="utf-8")
|
||||||
|
if wt_path.exists()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# Non-UTF-8 (binary/corrupt) working-tree file: record
|
||||||
|
# the change without a unified diff, which would
|
||||||
|
# otherwise be polluted with replacement characters and
|
||||||
|
# misrepresent the audit record.
|
||||||
|
changed += 1
|
||||||
|
summary_lines.append(f"{path}: binary or non-UTF-8 file changed")
|
||||||
|
continue
|
||||||
|
if head_text == wt_text:
|
||||||
|
continue
|
||||||
|
changed += 1
|
||||||
|
hunks = list(difflib.unified_diff(
|
||||||
|
head_text.splitlines(),
|
||||||
|
wt_text.splitlines(),
|
||||||
|
fromfile=path,
|
||||||
|
tofile=path,
|
||||||
|
lineterm="",
|
||||||
|
))
|
||||||
|
added = sum(1 for line in hunks if line.startswith("+") and not line.startswith("+++"))
|
||||||
|
removed = sum(1 for line in hunks if line.startswith("-") and not line.startswith("---"))
|
||||||
|
total_added += added
|
||||||
|
total_removed += removed
|
||||||
|
summary_lines.append(f"{path}: +{added} -{removed}")
|
||||||
|
diff_lines.extend(hunks)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Git summarize_working_tree failed")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if changed == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
diff_text = "\n".join(diff_lines)
|
||||||
|
if len(diff_text) > _WORKING_TREE_DIFF_MAX_CHARS:
|
||||||
|
diff_text = diff_text[:_WORKING_TREE_DIFF_MAX_CHARS] + "\n...[diff truncated]"
|
||||||
|
|
||||||
|
body = "\n".join(summary_lines)
|
||||||
|
body += (
|
||||||
|
f"\n{changed} file{'s' if changed != 1 else ''} changed, "
|
||||||
|
f"{total_added} insertion{'s' if total_added != 1 else ''}(+), "
|
||||||
|
f"{total_removed} deletion{'s' if total_removed != 1 else ''}(-)"
|
||||||
|
)
|
||||||
|
if diff_lines:
|
||||||
|
body += f"\n\n```diff\n{diff_text}\n```"
|
||||||
|
return body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _head_tree(repo) -> object | None:
|
||||||
|
"""Return the tree object at HEAD, or None if there are no commits."""
|
||||||
|
try:
|
||||||
|
head = repo.refs[b"HEAD"]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
commit = repo[head]
|
||||||
|
if commit.type_name != b"commit":
|
||||||
|
return None
|
||||||
|
return repo[commit.tree]
|
||||||
|
|
||||||
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
||||||
"""Find a commit by short SHA prefix match."""
|
"""Find a commit by short SHA prefix match."""
|
||||||
for c in self.log(max_entries=max_entries):
|
for c in self.log(max_entries=max_entries):
|
||||||
|
|||||||
+97
-27
@@ -61,6 +61,29 @@ 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):
|
||||||
|
"""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):
|
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)
|
||||||
store.dream_prompt_file.write_text(
|
store.dream_prompt_file.write_text(
|
||||||
@@ -566,48 +589,95 @@ class TestEphemeralHooks:
|
|||||||
spy.before_iteration.assert_called()
|
spy.before_iteration.assert_called()
|
||||||
|
|
||||||
class TestDreamCommitMessage:
|
class TestDreamCommitMessage:
|
||||||
async def test_commit_includes_response_summary(self, tmp_path):
|
def test_commit_message_reflects_real_diff_not_narrative(self, tmp_path):
|
||||||
"""Git auto-commit after Dream should include the LLM response in the body."""
|
"""The Dream commit message must mirror the real git diff and ignore the
|
||||||
import subprocess
|
LLM's narrative, so ``/dream-log`` can never lie.
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
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 = MemoryStore(tmp_path)
|
||||||
store.write_soul("# Soul")
|
store.write_soul("# Soul")
|
||||||
store.write_memory("# Memory")
|
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.init()
|
||||||
store.git.auto_commit("initial state")
|
store.git.auto_commit("initial state")
|
||||||
|
|
||||||
# Simulate what the cron handler does: produce a resp with content,
|
# A real edit to a tracked content file.
|
||||||
# build the commit message via the actual function, then commit.
|
store.write_memory("# Memory\n- DMSO research notes")
|
||||||
resp_content = "Identified 2 new facts about project goals"
|
|
||||||
resp = MagicMock(content=resp_content)
|
# 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(
|
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)
|
sha = store.git.auto_commit(msg)
|
||||||
assert sha is not None
|
assert sha is not None
|
||||||
|
|
||||||
log = subprocess.check_output(
|
log = subprocess.check_output(
|
||||||
["git", "log", "-1", "--format=%B"],
|
["git", "log", "-1", "--format=%B"],
|
||||||
cwd=str(tmp_path), text=True,
|
cwd=str(tmp_path), text=True,
|
||||||
).strip()
|
).strip()
|
||||||
assert "dream: periodic memory consolidation" in log
|
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() == ""
|
||||||
|
|||||||
@@ -20,10 +20,17 @@ from nanobot.utils.gitstore import CommitInfo
|
|||||||
|
|
||||||
|
|
||||||
class _FakeStore:
|
class _FakeStore:
|
||||||
def __init__(self, git, last_dream_cursor: int = 1, dream_prompt_result=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
git,
|
||||||
|
last_dream_cursor: int = 1,
|
||||||
|
dream_prompt_result=None,
|
||||||
|
content_diff: str = "",
|
||||||
|
):
|
||||||
self.git = git
|
self.git = git
|
||||||
self._last_dream_cursor = last_dream_cursor
|
self._last_dream_cursor = last_dream_cursor
|
||||||
self._dream_prompt_result = dream_prompt_result
|
self._dream_prompt_result = dream_prompt_result
|
||||||
|
self._content_diff = content_diff
|
||||||
self.compact_history_called = False
|
self.compact_history_called = False
|
||||||
|
|
||||||
def get_last_dream_cursor(self) -> int:
|
def get_last_dream_cursor(self) -> int:
|
||||||
@@ -38,6 +45,9 @@ class _FakeStore:
|
|||||||
def set_last_dream_cursor(self, value: int) -> None:
|
def set_last_dream_cursor(self, value: int) -> None:
|
||||||
self._last_dream_cursor = value
|
self._last_dream_cursor = value
|
||||||
|
|
||||||
|
def dream_content_diff(self) -> str:
|
||||||
|
return self._content_diff
|
||||||
|
|
||||||
def compact_history(self) -> None:
|
def compact_history(self) -> None:
|
||||||
self.compact_history_called = True
|
self.compact_history_called = True
|
||||||
|
|
||||||
@@ -159,6 +169,74 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None:
|
|||||||
assert callable(calls[0][1]["on_progress"])
|
assert callable(calls[0][1]["on_progress"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_runnable_dream(
|
||||||
|
tmp_path,
|
||||||
|
*,
|
||||||
|
initialized: bool,
|
||||||
|
content_diff: str,
|
||||||
|
stop_reason: str = "completed",
|
||||||
|
) -> tuple[CommandContext, _FakeStore]:
|
||||||
|
"""Build a /dream ctx whose run is driven by a canned stop reason + diff."""
|
||||||
|
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||||
|
store = _FakeStore(
|
||||||
|
_FakeGit(initialized=initialized),
|
||||||
|
last_dream_cursor=5,
|
||||||
|
dream_prompt_result=("dream prompt", 42),
|
||||||
|
content_diff=content_diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_direct(*args, **kwargs):
|
||||||
|
return OutboundMessage(
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
content="done",
|
||||||
|
metadata={"_stop_reason": stop_reason},
|
||||||
|
)
|
||||||
|
|
||||||
|
bus = _FakeBus()
|
||||||
|
sessions_dir = tmp_path / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
loop = SimpleNamespace(
|
||||||
|
bus=bus,
|
||||||
|
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||||
|
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||||
|
process_direct=process_direct,
|
||||||
|
)
|
||||||
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||||
|
return ctx, store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None:
|
||||||
|
"""A real file delta => productive run => cursor advances (Tier 3)."""
|
||||||
|
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="SOUL.md: +1 -0")
|
||||||
|
await cmd_dream(ctx)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert store._last_dream_cursor == 42
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dream_keeps_cursor_on_completed_noop(tmp_path) -> None:
|
||||||
|
"""Completed run with no file changes must NOT advance the cursor, so the
|
||||||
|
history batch is reconsidered next run instead of silently swallowed."""
|
||||||
|
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="")
|
||||||
|
await cmd_dream(ctx)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert store._last_dream_cursor == 5 # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dream_non_git_falls_back_to_completion_gate(tmp_path) -> None:
|
||||||
|
"""Without git there is no diff signal; productivity falls back to the
|
||||||
|
completion check so non-git workspaces keep working."""
|
||||||
|
ctx, store = _build_runnable_dream(
|
||||||
|
tmp_path, initialized=False, content_diff="", stop_reason="completed",
|
||||||
|
)
|
||||||
|
await cmd_dream(ctx)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert store._last_dream_cursor == 42 # advanced via completion fallback
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dream_log_latest_is_more_user_friendly() -> None:
|
async def test_dream_log_latest_is_more_user_friendly() -> None:
|
||||||
commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00")
|
commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00")
|
||||||
|
|||||||
@@ -98,6 +98,51 @@ class TestLineAges:
|
|||||||
assert age_by_line["- keep"] == 30
|
assert age_by_line["- keep"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarizeWorkingTree:
|
||||||
|
"""Ground-truth diff summary used to keep Dream audit records honest."""
|
||||||
|
|
||||||
|
def test_empty_when_not_initialized(self, tmp_path):
|
||||||
|
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
|
||||||
|
assert git.summarize_working_tree(["MEMORY.md"]) == ""
|
||||||
|
|
||||||
|
def test_empty_when_no_changes(self, git):
|
||||||
|
assert git.summarize_working_tree(["MEMORY.md", "SOUL.md"]) == ""
|
||||||
|
|
||||||
|
def test_summarizes_real_change(self, git, tmp_path):
|
||||||
|
(tmp_path / "MEMORY.md").write_text("# Memory\n- new fact\n", encoding="utf-8")
|
||||||
|
summary = git.summarize_working_tree(["MEMORY.md"])
|
||||||
|
assert "MEMORY.md: +2 -0" in summary
|
||||||
|
assert "new fact" in summary
|
||||||
|
assert "1 file changed, 2 insertions(+), 0 deletions(-)" in summary
|
||||||
|
|
||||||
|
def test_only_reports_requested_paths(self, git, tmp_path):
|
||||||
|
# MEMORY.md changes, but we only ask about the unchanged SOUL.md.
|
||||||
|
(tmp_path / "MEMORY.md").write_text("changed\n", encoding="utf-8")
|
||||||
|
assert git.summarize_working_tree(["SOUL.md"]) == ""
|
||||||
|
|
||||||
|
def test_counts_additions_and_removals(self, git, tmp_path):
|
||||||
|
(tmp_path / "MEMORY.md").write_text("# M\n- keep\n- new\n", encoding="utf-8")
|
||||||
|
summary = git.summarize_working_tree(["MEMORY.md"])
|
||||||
|
assert "MEMORY.md: +3 -0" in summary
|
||||||
|
|
||||||
|
def test_detects_deletion(self, git, tmp_path):
|
||||||
|
# File removed from the working tree (must have content first; the
|
||||||
|
# fixture's tracked files start empty, so an empty-file delete is a no-op).
|
||||||
|
(tmp_path / "MEMORY.md").write_text("has content\n", encoding="utf-8")
|
||||||
|
git.auto_commit("add content")
|
||||||
|
(tmp_path / "MEMORY.md").unlink()
|
||||||
|
summary = git.summarize_working_tree(["MEMORY.md"])
|
||||||
|
assert summary # a removal is still a change
|
||||||
|
assert "deletion" in summary
|
||||||
|
|
||||||
|
def test_non_utf8_file_marked_binary_without_replacement_chars(self, git, tmp_path):
|
||||||
|
# Invalid UTF-8 must not leak replacement chars into the audit record.
|
||||||
|
(tmp_path / "MEMORY.md").write_bytes(b"\x89PNG\r\n\x1a\n\xff\xfe\x00\x01")
|
||||||
|
summary = git.summarize_working_tree(["MEMORY.md"])
|
||||||
|
assert "MEMORY.md: binary or non-UTF-8 file changed" in summary
|
||||||
|
assert "\ufffd" not in summary # no U+FFFD replacement chars leaked
|
||||||
|
|
||||||
|
|
||||||
class TestNestedRepoProtection:
|
class TestNestedRepoProtection:
|
||||||
"""Regression tests for GitHub issue #2980: nested repo protection."""
|
"""Regression tests for GitHub issue #2980: nested repo protection."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user