fix(memory): expose media references to session consolidation (#5157)

Co-authored-by: shakewingo <yaoyingshakewin@gmail.com>
Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com>
This commit is contained in:
chengyongru 2026-07-29 15:18:44 +08:00 committed by GitHub
parent 393d429e0a
commit e703481755
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 96 additions and 9 deletions

View File

@ -19,6 +19,7 @@ from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.gitstore import GitStore from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir, ensure_dir,
estimate_message_tokens, estimate_message_tokens,
estimate_prompt_tokens_chain, estimate_prompt_tokens_chain,
@ -695,11 +696,17 @@ class MemoryStore:
def _format_messages(messages: list[dict]) -> str: def _format_messages(messages: list[dict]) -> str:
lines = [] lines = []
for message in messages: 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 continue
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else "" tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
lines.append( 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) return "\n".join(lines)

View File

@ -22,10 +22,10 @@ from nanobot.runtime_context import (
public_history_message, public_history_message,
) )
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir, ensure_dir,
estimate_message_tokens, estimate_message_tokens,
find_legal_message_start, find_legal_message_start,
image_placeholder_text,
recent_message_start_index, recent_message_start_index,
safe_filename, safe_filename,
strip_think, strip_think,
@ -214,12 +214,11 @@ class Session:
# image used to be. Without this, an image-only user turn # image used to be. Without this, an image-only user turn
# replays as an empty user message — the assistant's reply then # replays as an empty user message — the assistant's reply then
# looks like it's responding to nothing. # looks like it's responding to nothing.
media = message.get("media") content = content_with_media_breadcrumbs(
if role == "user" and isinstance(media, list) and media and isinstance(content, str): role,
breadcrumbs = "\n".join( content,
image_placeholder_text(p) for p in media if isinstance(p, str) and p message.get("media"),
) )
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
cli_apps = message.get("cli_apps") cli_apps = message.get("cli_apps")
if ( if (
include_runtime_context include_runtime_context

View File

@ -367,6 +367,24 @@ def image_placeholder_text(path: str | None, *, empty: str = "[image]") -> str:
return f"[image: {path}]" if path else empty 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: def truncate_text(text: str, max_chars: int) -> str:
"""Truncate text with a stable suffix.""" """Truncate text with a stable suffix."""
if max_chars <= 0 or len(text) <= max_chars: if max_chars <= 0 or len(text) <= max_chars:

View File

@ -75,6 +75,41 @@ def _tool_round(call_id: str) -> list[dict]:
class TestConsolidatorSummarize: 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( async def test_archive_excludes_model_only_runtime_context(
self, consolidator, mock_provider, runtime self, consolidator, mock_provider, runtime
): ):

View File

@ -7,6 +7,7 @@ import tiktoken
from nanobot.utils import helpers from nanobot.utils import helpers
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
_write_text_atomic, _write_text_atomic,
content_with_media_breadcrumbs,
current_time_str, current_time_str,
split_message, split_message,
truncate_text_to_tokens, truncate_text_to_tokens,
@ -55,6 +56,33 @@ def test_current_time_str_rejects_unknown_timezone():
current_time_str("Not/AZone") 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( def test_write_text_atomic_fsyncs_file_and_parent_directory(
tmp_path: Path, monkeypatch tmp_path: Path, monkeypatch
) -> None: ) -> None: