fix(agent): keep runtime context within its lifecycle

This commit is contained in:
Xubin Ren
2026-07-12 00:35:17 +08:00
parent c339ce8bba
commit 01e4f3762a
6 changed files with 116 additions and 5 deletions
+8 -2
View File
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
from loguru import logger from loguru import logger
from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session from nanobot.session.manager import Session
from nanobot.utils.gitstore import GitStore from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
@@ -660,7 +661,10 @@ class MemoryStore:
) -> None: ) -> None:
"""Fallback: dump raw messages to history.jsonl without LLM summarization.""" """Fallback: dump raw messages to history.jsonl without LLM summarization."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS 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( self.append_history(
f"[RAW] {len(messages)} messages\n" f"[RAW] {len(messages)} messages\n"
f"{formatted}", f"{formatted}",
@@ -932,7 +936,9 @@ class Consolidator:
""" """
if not messages: if not messages:
return None 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: try:
formatted = MemoryStore._format_messages(messages_to_summarize) formatted = MemoryStore._format_messages(messages_to_summarize)
formatted = self._truncate_to_token_budget(formatted, runtime=runtime) formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
+6 -2
View File
@@ -112,8 +112,8 @@ class SessionClient:
session = self._loop.sessions.get_or_create(key) session = self._loop.sessions.get_or_create(key)
if session.messages: if session.messages:
raise ValueError(f"restore target session is not empty: {key}") 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: for raw in snapshot.messages:
if "role" not in raw or "content" not in raw: if "role" not in raw or "content" not in raw:
raise ValueError("restored messages must include role and content") raise ValueError("restored messages must include role and content")
@@ -125,7 +125,11 @@ class SessionClient:
for field, value in raw.items() for field, value in raw.items()
if field not in {"role", "content"} 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: if save:
self._loop.sessions.save(session) self._loop.sessions.save(session)
+1 -1
View File
@@ -746,7 +746,7 @@ class SessionManager:
found_target = True found_target = True
break break
user_index += 1 user_index += 1
copied.append(deepcopy(message)) copied.append(public_history_message(message))
if user_index == before_user_index: if user_index == before_user_index:
found_target = True found_target = True
if not found_target: if not found_target:
+46
View File
@@ -11,6 +11,11 @@ from nanobot.agent.memory import (
MemoryStore, MemoryStore,
) )
from nanobot.providers.base import GenerationSettings, LLMResponse 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.session.manager import Session
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
@@ -70,6 +75,31 @@ def _tool_round(call_id: str) -> list[dict]:
class TestConsolidatorSummarize: 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( async def test_archive_uses_captured_generation(
self, consolidator, mock_provider, runtime self, consolidator, mock_provider, runtime
): ):
@@ -915,6 +945,22 @@ class TestRawArchiveTruncation:
assert len(entries) == 1 assert len(entries) == 1
assert "hello" in entries[0]["content"] 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): def test_raw_archive_preserves_session_key(self, store):
messages = [{"role": "user", "content": "hello"}] messages = [{"role": "user", "content": "hello"}]
store.raw_archive(messages, session_key="websocket:chat-1") store.raw_archive(messages, session_key="websocket:chat-1")
@@ -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"] 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): def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source") source = manager.get_or_create("websocket:source")
+21
View File
@@ -1296,6 +1296,27 @@ async def test_session_ingest_cannot_restore_runtime_context_marker(tmp_path):
assert RUNTIME_CONTEXT_HISTORY_META not in stored 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): def test_memory_helpers_read_write_append_and_filter_history(tmp_path):
config_path = _write_config(tmp_path) config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path) bot = Nanobot.from_config(config_path, workspace=tmp_path)