From 15e42059bd7dd9a13a9171a9360cb4e924d91cfd Mon Sep 17 00:00:00 2001 From: shixi-li Date: Thu, 23 Jul 2026 15:33:30 +0800 Subject: [PATCH] fix(memory): progress past completed no-op batches --- nanobot/agent/memory.py | 9 ++-- nanobot/cli/commands.py | 27 +++++----- nanobot/command/builtin.py | 17 +++---- tests/command/test_builtin_dream.py | 78 ++++++++++++++++++++++++++--- 4 files changed, 98 insertions(+), 33 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index eb122fd8..3b126abb 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -47,9 +47,9 @@ class MemoryStore: """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md.""" _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. + # Durable files whose real working-tree delta grounds Dream commit messages. + # 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 @@ -586,8 +586,7 @@ class MemoryStore: """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). + the ground-truth input for diff-grounded Dream commit messages. """ if not self._git.is_initialized(): return "" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a91f408d..4961fcdf 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1846,20 +1846,23 @@ def _run_gateway( tools=store.build_dream_tools(), on_progress=_silent, ) - # Ground truth: the real file delta, not the LLM's self-report. + # The real file delta grounds the audit record; clean completion + # decides whether this history batch has finished processing. diff_body = store.dream_content_diff() - productive = bool(diff_body) or ( - not store.git.is_initialized() - and MemoryStore.dream_run_completed(resp) - ) - if productive: + completed = MemoryStore.dream_run_completed(resp) + if completed: store.set_last_dream_cursor(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", - ) + if diff_body: + logger.info( + "Dream cron job completed, cursor advanced to {}", + last_cursor, + ) + else: + logger.info( + "Dream cron job completed with no memory changes; " + "cursor advanced to {}", + last_cursor, + ) else: logger.warning( "Dream cron job did not complete; cursor remains at {}", diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index a9289001..672f2202 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -437,17 +437,16 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage: on_progress=_silent, ) elapsed = time.monotonic() - t0 - # Ground truth: the real file delta, not the LLM's self-report. + # The real file delta grounds the audit record; clean completion + # decides whether this history batch has finished processing. diff_body = store.dream_content_diff() - productive = bool(diff_body) or ( - not store.git.is_initialized() - and MemoryStore.dream_run_completed(resp) - ) - if productive: + completed = MemoryStore.dream_run_completed(resp) + if completed: store.set_last_dream_cursor(last_cursor) - 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." + if diff_body: + content = f"Dream completed in {elapsed:.1f}s." + else: + content = f"Dream completed in {elapsed:.1f}s; no memory changes." else: content = ( f"Dream did not complete after {elapsed:.1f}s; " diff --git a/tests/command/test_builtin_dream.py b/tests/command/test_builtin_dream.py index c3fdcf7a..df7bbf0e 100644 --- a/tests/command/test_builtin_dream.py +++ b/tests/command/test_builtin_dream.py @@ -225,7 +225,7 @@ def _build_runnable_dream( @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).""" + """A completed run with a real file delta advances the cursor.""" ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="SOUL.md: +1 -0") await cmd_dream(ctx) await asyncio.sleep(0) @@ -233,19 +233,83 @@ async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None: @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.""" +async def test_dream_advances_cursor_on_completed_noop(tmp_path) -> None: + """A completed no-op has processed the batch and must not repeat it.""" 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 + assert store._last_dream_cursor == 42 + assert "no memory changes" in ctx.loop.bus.outbound[0].content + + +@pytest.mark.asyncio +async def test_dream_keeps_cursor_when_incomplete_with_diff(tmp_path) -> None: + """An incomplete run remains retryable even if it left a partial edit.""" + ctx, store = _build_runnable_dream( + tmp_path, + initialized=True, + content_diff="SOUL.md: +1 -0", + stop_reason="length", + ) + await cmd_dream(ctx) + await asyncio.sleep(0) + assert store._last_dream_cursor == 5 + assert "did not complete" in ctx.loop.bus.outbound[0].content + + +@pytest.mark.asyncio +async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None: + """A no-op first batch must not starve later history entries.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + store = MemoryStore(workspace) + store.write_soul("# Soul") + store.write_memory("# Memory") + for index in range(1, 22): + store.append_history(f"entry-{index:02d}") + store.git.init() + + processed_prompts: list[str] = [] + + async def process_direct(prompt, *args, **kwargs): + processed_prompts.append(prompt) + return OutboundMessage( + channel="cli", + chat_id="direct", + content="done", + metadata={"_stop_reason": "completed"}, + ) + + msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream") + 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) + + await cmd_dream(ctx) + await asyncio.sleep(0) + + assert len(processed_prompts) == 1 + assert "entry-20" in processed_prompts[0] + assert "entry-21" not in processed_prompts[0] + assert store.get_last_dream_cursor() == 20 + next_result = store.build_dream_prompt() + assert next_result is not None + next_prompt, next_cursor = next_result + assert next_cursor == 21 + assert "entry-21" in next_prompt + assert "entry-01" not in next_prompt @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.""" + """Non-git workspaces use the same clean-completion gate.""" ctx, store = _build_runnable_dream( tmp_path, initialized=False, content_diff="", stop_reason="completed", )