diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 3e92ebb7e..e3dcc9a7f 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -19,6 +19,7 @@ from nanobot.runtime_context import public_history_messages from nanobot.session.manager import Session, SessionManager from nanobot.utils.gitstore import GitStore from nanobot.utils.helpers import ( + content_with_media_breadcrumbs, ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, @@ -695,11 +696,17 @@ class MemoryStore: def _format_messages(messages: list[dict]) -> str: lines = [] for message in messages: - if not message.get("content"): + content = content_with_media_breadcrumbs( + message.get("role"), + message.get("content", ""), + message.get("media"), + ) + if not content: continue tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else "" lines.append( - f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}" + f"[{message.get('timestamp', '?')[:16]}] " + f"{message['role'].upper()}{tools}: {content}" ) return "\n".join(lines) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 21c776eb3..98ccce75e 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -22,10 +22,10 @@ from nanobot.runtime_context import ( public_history_message, ) from nanobot.utils.helpers import ( + content_with_media_breadcrumbs, ensure_dir, estimate_message_tokens, find_legal_message_start, - image_placeholder_text, recent_message_start_index, safe_filename, strip_think, @@ -214,12 +214,11 @@ class Session: # image used to be. Without this, an image-only user turn # replays as an empty user message — the assistant's reply then # looks like it's responding to nothing. - media = message.get("media") - if role == "user" and isinstance(media, list) and media and isinstance(content, str): - breadcrumbs = "\n".join( - image_placeholder_text(p) for p in media if isinstance(p, str) and p - ) - content = f"{content}\n{breadcrumbs}" if content else breadcrumbs + content = content_with_media_breadcrumbs( + role, + content, + message.get("media"), + ) cli_apps = message.get("cli_apps") if ( include_runtime_context diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 64e27770c..b2ab7b495 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -367,6 +367,24 @@ def image_placeholder_text(path: str | None, *, empty: str = "[image]") -> str: return f"[image: {path}]" if path else empty +def content_with_media_breadcrumbs( + role: str | None, + content: Any, + media: Any, +) -> Any: + """Append persisted user-media breadcrumbs to plain-text content.""" + if role != "user" or not isinstance(content, str) or not isinstance(media, list): + return content + breadcrumbs = "\n".join( + image_placeholder_text(path) + for path in media + if isinstance(path, str) and path + ) + if not breadcrumbs: + return content + return f"{content}\n{breadcrumbs}" if content else breadcrumbs + + def truncate_text(text: str, max_chars: int) -> str: """Truncate text with a stable suffix.""" if max_chars <= 0 or len(text) <= max_chars: diff --git a/tests/agent/test_consolidator.py b/tests/agent/test_consolidator.py index 1e48b595b..9f17285c9 100644 --- a/tests/agent/test_consolidator.py +++ b/tests/agent/test_consolidator.py @@ -75,6 +75,41 @@ def _tool_round(call_id: str) -> list[dict]: class TestConsolidatorSummarize: + async def test_archive_prompt_includes_media_breadcrumb( + self, consolidator, mock_provider, store, runtime + ): + path = "/home/user/.nanobot/media/websocket/upload_photo.png" + summary = "User uploaded a photo." + mock_provider.chat_with_retry.return_value = MagicMock( + content=summary, + finish_reason="stop", + ) + + result = await consolidator.archive( + [{"role": "user", "content": "please inspect this", "media": [path]}], + runtime=runtime, + ) + + prompt = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"] + entries = store.read_unprocessed_history(since_cursor=0) + assert f"[image: {path}]" in prompt + assert result == summary + assert [entry["content"] for entry in entries] == [summary] + + def test_format_messages_keeps_media_only_user_turn(self): + path = "/home/user/.nanobot/media/websocket/clip.mp4" + + formatted = MemoryStore._format_messages([ + { + "role": "user", + "content": "", + "media": [path], + "timestamp": "2026-07-27", + } + ]) + + assert formatted == f"[2026-07-27] USER: [image: {path}]" + async def test_archive_excludes_model_only_runtime_context( self, consolidator, mock_provider, runtime ): diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index b3a95f72c..a526d17c6 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -7,6 +7,7 @@ import tiktoken from nanobot.utils import helpers from nanobot.utils.helpers import ( _write_text_atomic, + content_with_media_breadcrumbs, current_time_str, split_message, truncate_text_to_tokens, @@ -55,6 +56,33 @@ def test_current_time_str_rejects_unknown_timezone(): current_time_str("Not/AZone") +def test_content_with_media_breadcrumbs_preserves_valid_paths(): + assert content_with_media_breadcrumbs( + "user", + "review these", + ["/media/report.pdf", "/media/clip.mp4"], + ) == ( + "review these\n" + "[image: /media/report.pdf]\n" + "[image: /media/clip.mp4]" + ) + + +def test_content_with_media_breadcrumbs_only_rewrites_plain_user_content(): + structured = [{"type": "text", "text": "hello"}] + + assert content_with_media_breadcrumbs( + "assistant", + "done", + ["/media/output.png"], + ) == "done" + assert content_with_media_breadcrumbs( + "user", + structured, + ["/media/input.png"], + ) is structured + + def test_write_text_atomic_fsyncs_file_and_parent_directory( tmp_path: Path, monkeypatch ) -> None: