mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
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:
parent
393d429e0a
commit
e703481755
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
):
|
):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user