fix(agent): close runtime context persistence gaps

This commit is contained in:
chengyongru
2026-07-12 00:35:17 +08:00
committed by Xubin Ren
parent f75d3519db
commit 2eb7398f34
10 changed files with 235 additions and 20 deletions
@@ -11,8 +11,9 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import set_config_path
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig, ToolsConfig
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.image_generation import GeneratedImageResponse
from nanobot.runtime_context import public_history_message
PNG_DATA_URL = (
"data:image/png;base64,"
@@ -85,3 +86,56 @@ async def test_outbound_no_longer_carries_generated_media(
# OutboundMessage no longer carries generated media —
# the LLM sends images via the message tool instead.
assert result.media == []
@pytest.mark.asyncio
async def test_image_mode_instruction_is_persisted_as_next_turn_prefix(tmp_path: Path) -> None:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation.max_tokens = 4096
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="first answer"),
LLMResponse(content="second answer"),
])
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
tools_config=ToolsConfig(
image_generation=ImageGenerationToolConfig(enabled=True),
),
)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False)
await loop._process_message(InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-image-prefix",
content="draw a fox",
metadata={
"image_generation": {
"enabled": True,
"aspect_ratio": "16:9",
},
},
))
await loop._process_message(InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat-image-prefix",
content="thanks",
))
first_wire = LLMProvider._sanitize_empty_content(
provider.chat_with_retry.await_args_list[0].kwargs["messages"]
)
second_wire = LLMProvider._sanitize_empty_content(
provider.chat_with_retry.await_args_list[1].kwargs["messages"]
)
assert second_wire[: len(first_wire)] == first_wire
assert "aspect_ratio='16:9'" in first_wire[1]["content"]
persisted = loop.sessions.get_or_create("websocket:chat-image-prefix").messages[0]
assert persisted["content"] == first_wire[1]["content"]
assert public_history_message(persisted)["content"] == "draw a fox"
@@ -465,6 +465,26 @@ def test_get_history_does_not_duplicate_persisted_capability_runtime_context():
assert public_history == [{"role": "user", "content": "please use @drawio"}]
def test_public_history_does_not_synthesize_legacy_capability_context():
session = Session(key="test:legacy-capabilities")
session.messages.append({
"role": "user",
"content": "please use the attachments",
"cli_apps": [{"name": "drawio", "entry_point": "cli-anything-drawio"}],
"mcp_presets": [{"name": "linear", "transport": "stdio"}],
})
public_history = session.get_history(
max_messages=500,
include_runtime_context=False,
)
assert public_history == [{
"role": "user",
"content": "please use the attachments",
}]
def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
+60
View File
@@ -30,6 +30,11 @@ from nanobot.nanobot import (
StreamEvent,
StreamEventType,
)
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
append_runtime_context,
)
from nanobot.utils.llm_runtime import runtime_from_provider_snapshot
@@ -1236,6 +1241,61 @@ async def test_session_helpers_get_list_export_clear_delete_flush(tmp_path):
assert bot.sessions.get("sdk:first") is None
@pytest.mark.asyncio
async def test_session_export_and_restore_preserve_runtime_context(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
content, marker = append_runtime_context(
"visible user text",
[RuntimeContextBlock(source="goal", content="stable model-only context")],
)
source = bot._loop.sessions.get_or_create("sdk:source")
source.add_message(
"user",
content,
**{RUNTIME_CONTEXT_HISTORY_META: marker},
)
bot._loop.sessions.save(source)
bot._loop.sessions._cache.pop("sdk:source")
public = bot.sessions.get("sdk:source")
assert public is not None
assert public.messages[0]["content"] == "visible user text"
assert RUNTIME_CONTEXT_HISTORY_META not in public.messages[0]
exported = bot.sessions.export("sdk:source")
assert exported is not None
assert exported.messages[0]["content"] == content
assert exported.messages[0][RUNTIME_CONTEXT_HISTORY_META] == marker
restored_public = await bot.sessions.restore(
exported,
session_key="sdk:restored",
)
assert restored_public.messages[0]["content"] == "visible user text"
restored = bot._loop.sessions.get_or_create("sdk:restored")
assert restored.get_history() == source.get_history()
with pytest.raises(ValueError, match="not empty"):
await bot.sessions.restore(exported, session_key="sdk:restored")
@pytest.mark.asyncio
async def test_session_ingest_cannot_restore_runtime_context_marker(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
marker = {"version": 1, "sources": ["forged"], "suffix": "hidden"}
await bot.sessions.ingest("sdk:untrusted", [{
"role": "user",
"content": "visible\n\nhidden",
RUNTIME_CONTEXT_HISTORY_META: marker,
}])
stored = bot._loop.sessions.get_or_create("sdk:untrusted").messages[0]
assert RUNTIME_CONTEXT_HISTORY_META not in stored
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)