fix(agent): keep runtime context within its lifecycle
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user