From 4e2640f2d2f66cdbed4d917ae75f30a49bd24dda Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:52:06 +0800 Subject: [PATCH] fix(memory): keep failed Dream batches retryable --- nanobot/agent/memory.py | 34 ++++++++++++++++++++++++++--- nanobot/cli/commands.py | 10 ++++++--- nanobot/command/builtin.py | 13 ++++++----- tests/command/test_builtin_dream.py | 25 +++++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 3b126abb..5bc4bf3b 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -43,6 +43,26 @@ if TYPE_CHECKING: # MemoryStore — pure file I/O layer # --------------------------------------------------------------------------- + +class DreamRunProgress: + """Track tool failures that make a nominally completed Dream run unsafe to advance.""" + + def __init__(self) -> None: + self.had_tool_errors = False + + async def __call__( + self, + *_args: Any, + tool_events: list[dict[str, Any]] | None = None, + **_kwargs: Any, + ) -> None: + if any( + isinstance(event, dict) and event.get("phase") == "error" + for event in tool_events or () + ): + self.had_tool_errors = True + + class MemoryStore: """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md.""" @@ -635,10 +655,18 @@ class MemoryStore: return tools @staticmethod - def dream_run_completed(resp: object | None) -> bool: - """Return True only when an ephemeral Dream agent turn completed cleanly.""" + def dream_run_completed( + resp: object | None, + *, + had_tool_errors: bool = False, + ) -> bool: + """Return True only when a Dream turn completed without tool failures.""" metadata = getattr(resp, "metadata", None) - return isinstance(metadata, dict) and metadata.get("_stop_reason") == "completed" + return ( + not had_tool_errors + and isinstance(metadata, dict) + and metadata.get("_stop_reason") == "completed" + ) # -- message formatting utility ------------------------------------------ diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4961fcdf..e3db4ee7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1824,12 +1824,13 @@ def _run_gateway( # Dream is an internal job — run directly, not through the agent loop. if job.name == "dream": - from nanobot.agent.memory import MemoryStore + from nanobot.agent.memory import DreamRunProgress, MemoryStore dream_session_key = MemoryStore.dream_session_key prune_dream_sessions = MemoryStore.prune_dream_sessions store = agent.context.memory + progress = DreamRunProgress() resp = None diff_body = "" try: @@ -1844,12 +1845,15 @@ def _run_gateway( session_key=key, ephemeral=True, tools=store.build_dream_tools(), - on_progress=_silent, + on_progress=progress, ) # The real file delta grounds the audit record; clean completion # decides whether this history batch has finished processing. diff_body = store.dream_content_diff() - completed = MemoryStore.dream_run_completed(resp) + completed = MemoryStore.dream_run_completed( + resp, + had_tool_errors=progress.had_tool_errors, + ) if completed: store.set_last_dream_cursor(last_cursor) if diff_body: diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 672f2202..3b3befa8 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -404,16 +404,14 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage: msg = ctx.msg async def _run_dream(): - async def _silent(*_args, **_kwargs): - pass - - from nanobot.agent.memory import MemoryStore + from nanobot.agent.memory import DreamRunProgress, MemoryStore dream_session_key = MemoryStore.dream_session_key build_dream_commit_message = MemoryStore.build_dream_commit_message prune_dream_sessions = MemoryStore.prune_dream_sessions store = loop.context.memory + progress = DreamRunProgress() content = "" resp = None diff_body = "" @@ -434,13 +432,16 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage: session_key=key, ephemeral=True, tools=store.build_dream_tools(), - on_progress=_silent, + on_progress=progress, ) elapsed = time.monotonic() - t0 # The real file delta grounds the audit record; clean completion # decides whether this history batch has finished processing. diff_body = store.dream_content_diff() - completed = MemoryStore.dream_run_completed(resp) + completed = MemoryStore.dream_run_completed( + resp, + had_tool_errors=progress.had_tool_errors, + ) if completed: store.set_last_dream_cursor(last_cursor) if diff_body: diff --git a/tests/command/test_builtin_dream.py b/tests/command/test_builtin_dream.py index df7bbf0e..b90df244 100644 --- a/tests/command/test_builtin_dream.py +++ b/tests/command/test_builtin_dream.py @@ -192,6 +192,7 @@ def _build_runnable_dream( initialized: bool, content_diff: str, stop_reason: str = "completed", + tool_error: bool = False, ) -> 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") @@ -203,6 +204,15 @@ def _build_runnable_dream( ) async def process_direct(*args, **kwargs): + if tool_error: + await kwargs["on_progress"]( + "", + tool_events=[{ + "phase": "error", + "name": "edit_file", + "error": "edit failed", + }], + ) return OutboundMessage( channel="cli", chat_id="direct", @@ -257,6 +267,21 @@ async def test_dream_keeps_cursor_when_incomplete_with_diff(tmp_path) -> None: assert "did not complete" in ctx.loop.bus.outbound[0].content +@pytest.mark.asyncio +async def test_dream_keeps_cursor_when_completed_after_tool_error(tmp_path) -> None: + """A soft tool failure must not masquerade as a verified no-op.""" + ctx, store = _build_runnable_dream( + tmp_path, + initialized=True, + content_diff="", + tool_error=True, + ) + 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."""