diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 753b261d..7e760269 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator from loguru import logger +from nanobot.runtime_context import public_history_messages from nanobot.session.manager import Session from nanobot.utils.gitstore import GitStore from nanobot.utils.helpers import ( @@ -660,7 +661,10 @@ class MemoryStore: ) -> None: """Fallback: dump raw messages to history.jsonl without LLM summarization.""" limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS - formatted = truncate_text(self._format_messages(messages), limit) + formatted = truncate_text( + self._format_messages(public_history_messages(messages)), + limit, + ) self.append_history( f"[RAW] {len(messages)} messages\n" f"{formatted}", @@ -932,7 +936,9 @@ class Consolidator: """ if not messages: return None - messages_to_summarize = summary_messages if summary_messages is not None else messages + messages_to_summarize = public_history_messages( + summary_messages if summary_messages is not None else messages + ) try: formatted = MemoryStore._format_messages(messages_to_summarize) formatted = self._truncate_to_token_budget(formatted, runtime=runtime) diff --git a/nanobot/sdk/clients.py b/nanobot/sdk/clients.py index 93a7e448..13bff038 100644 --- a/nanobot/sdk/clients.py +++ b/nanobot/sdk/clients.py @@ -112,8 +112,8 @@ class SessionClient: session = self._loop.sessions.get_or_create(key) if session.messages: raise ValueError(f"restore target session is not empty: {key}") - session.metadata.update(deepcopy(snapshot.metadata)) + prepared: list[tuple[str, Any, dict[str, Any]]] = [] for raw in snapshot.messages: if "role" not in raw or "content" not in raw: raise ValueError("restored messages must include role and content") @@ -125,7 +125,11 @@ class SessionClient: for field, value in raw.items() if field not in {"role", "content"} } - session.add_message(role, deepcopy(raw["content"]), **extra) + prepared.append((role, deepcopy(raw["content"]), extra)) + + session.metadata.update(deepcopy(snapshot.metadata)) + for role, content, extra in prepared: + session.add_message(role, content, **extra) if save: self._loop.sessions.save(session) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index bc702a52..87fde59c 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -746,7 +746,7 @@ class SessionManager: found_target = True break user_index += 1 - copied.append(deepcopy(message)) + copied.append(public_history_message(message)) if user_index == before_user_index: found_target = True if not found_target: diff --git a/tests/agent/test_consolidator.py b/tests/agent/test_consolidator.py index 4b949ae2..33af0930 100644 --- a/tests/agent/test_consolidator.py +++ b/tests/agent/test_consolidator.py @@ -11,6 +11,11 @@ from nanobot.agent.memory import ( MemoryStore, ) from nanobot.providers.base import GenerationSettings, LLMResponse +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + RuntimeContextBlock, + append_runtime_context, +) from nanobot.session.manager import Session from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.prompt_templates import render_template @@ -70,6 +75,31 @@ def _tool_round(call_id: str) -> list[dict]: class TestConsolidatorSummarize: + async def test_archive_excludes_model_only_runtime_context( + self, consolidator, mock_provider, runtime + ): + content, marker = append_runtime_context( + "ship the feature", + [RuntimeContextBlock(source="goal", content="host-only goal guidance")], + ) + mock_provider.chat_with_retry.return_value = MagicMock( + content="User wants to ship the feature.", + finish_reason="stop", + ) + + await consolidator.archive( + [{ + "role": "user", + "content": content, + RUNTIME_CONTEXT_HISTORY_META: marker, + }], + runtime=runtime, + ) + + prompt = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"] + assert "ship the feature" in prompt + assert "host-only goal guidance" not in prompt + async def test_archive_uses_captured_generation( self, consolidator, mock_provider, runtime ): @@ -915,6 +945,22 @@ class TestRawArchiveTruncation: assert len(entries) == 1 assert "hello" in entries[0]["content"] + def test_raw_archive_excludes_model_only_runtime_context(self, store): + content, marker = append_runtime_context( + "ship the feature", + [RuntimeContextBlock(source="goal", content="host-only goal guidance")], + ) + + store.raw_archive([{ + "role": "user", + "content": content, + RUNTIME_CONTEXT_HISTORY_META: marker, + }]) + + entry = store.read_unprocessed_history(since_cursor=0)[0]["content"] + assert "ship the feature" in entry + assert "host-only goal guidance" not in entry + def test_raw_archive_preserves_session_key(self, store): messages = [{"role": "user", "content": "hello"}] store.raw_archive(messages, session_key="websocket:chat-1") diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 36f60c89..6241e1f9 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -507,6 +507,40 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path): assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] +def test_fork_session_drops_source_runtime_context(tmp_path): + manager = SessionManager(tmp_path) + source = manager.get_or_create("websocket:source") + content, marker = append_runtime_context( + "round1", + [ + RuntimeContextBlock(source="goal", content="host-only goal guidance"), + RuntimeContextBlock(source="cli_apps", content="attached CLI App context"), + ], + ) + source.add_message( + "user", + content, + cli_apps=[{"name": "drawio", "entry_point": "cli-anything-drawio"}], + **{RUNTIME_CONTEXT_HISTORY_META: marker}, + ) + source.add_message("assistant", "answer1") + manager.save(source) + + forked = manager.fork_session_before_user_index( + "websocket:source", + "websocket:fork", + 1, + ) + + assert forked is not None + assert forked.messages[0]["content"] == "round1" + assert RUNTIME_CONTEXT_HISTORY_META not in forked.messages[0] + model_content = forked.get_history()[0]["content"] + assert model_content.startswith("round1") + assert "CLI App Attachment: @drawio" in model_content + assert "host-only goal guidance" not in model_content + + def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path): manager = SessionManager(tmp_path) source = manager.get_or_create("websocket:source") diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index b9d2ba97..b1462a39 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -1296,6 +1296,27 @@ async def test_session_ingest_cannot_restore_runtime_context_marker(tmp_path): assert RUNTIME_CONTEXT_HISTORY_META not in stored +@pytest.mark.asyncio +async def test_session_restore_validates_before_mutating_target(tmp_path): + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + snapshot = SessionSnapshot( + key="sdk:broken", + messages=[ + {"role": "user", "content": "valid first message"}, + {"role": "invalid", "content": "bad second message"}, + ], + metadata={"title": "must not leak"}, + ) + + with pytest.raises(ValueError, match="unsupported message role"): + await bot.sessions.restore(snapshot) + + target = bot._loop.sessions.get_or_create("sdk:broken") + assert target.messages == [] + assert "title" not in target.metadata + + def test_memory_helpers_read_write_append_and_filter_history(tmp_path): config_path = _write_config(tmp_path) bot = Nanobot.from_config(config_path, workspace=tmp_path)