feat(agent): add persistent runtime context providers

This commit is contained in:
chengyongru
2026-07-12 00:35:17 +08:00
committed by Xubin Ren
parent 7f8c3453e1
commit f75d3519db
27 changed files with 789 additions and 371 deletions
+60 -57
View File
@@ -18,8 +18,15 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.base import LLMResponse
from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RUNTIME_CONTEXT_MESSAGE_META,
RuntimeContextBlock,
append_runtime_context,
public_history_message,
)
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
@@ -48,19 +55,13 @@ def _mk_loop() -> AgentLoop:
return loop
def _host_text_message(content: str, suffix: str) -> dict:
def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict:
merged, marker = append_runtime_context(content, blocks)
assert marker is not None
return {
"role": "user",
"content": content,
"_meta": {ContextBuilder._HOST_TEXT_SUFFIX_META_KEY: suffix},
}
def _host_text_block(text: str) -> dict:
return {
"type": "text",
"text": text,
"_meta": {ContextBuilder._HOST_BLOCK_META_KEY: True},
"content": merged,
"_meta": {RUNTIME_CONTEXT_MESSAGE_META: marker},
}
@@ -357,74 +358,80 @@ def test_webui_title_update_uses_captured_llm_runtime(
assert captured["model"] == "turn-model"
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
loop = _mk_loop()
session = Session(key="test:runtime-only")
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
block = RuntimeContextBlock(source="test", content="provider context")
loop._save_turn(
session,
[{"role": "user", "content": [_host_text_block(runtime)]}],
[_runtime_message([], [block])],
skip=0,
)
assert session.messages == []
assert session.messages[0]["content"] == [
{"type": "text", "text": "provider context"}
]
assert public_history_message(session.messages[0])["content"] == []
def test_save_turn_keeps_image_placeholder_with_path_after_runtime_strip() -> None:
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:image")
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
block = RuntimeContextBlock(source="test", content="provider context")
loop._save_turn(
session,
[{
"role": "user",
"content": [
[_runtime_message(
[
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
_host_text_block(runtime),
],
}],
[block],
)],
skip=0,
)
assert session.messages[0]["content"] == [{"type": "text", "text": "[image: /media/feishu/photo.jpg]"}]
assert session.messages[0]["content"] == [
{"type": "text", "text": "[image: /media/feishu/photo.jpg]"},
{"type": "text", "text": "provider context"},
]
assert public_history_message(session.messages[0])["content"] == [
{"type": "text", "text": "[image: /media/feishu/photo.jpg]"}
]
def test_save_turn_keeps_image_placeholder_without_meta() -> None:
loop = _mk_loop()
session = Session(key="test:image-no-meta")
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
block = RuntimeContextBlock(source="test", content="provider context")
loop._save_turn(
session,
[{
"role": "user",
"content": [
[_runtime_message(
[
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
_host_text_block(runtime),
],
}],
[block],
)],
skip=0,
)
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
assert session.messages[0]["content"] == [
{"type": "text", "text": "[image]"},
{"type": "text", "text": "provider context"},
]
def test_save_turn_strips_host_guidance_suffix_from_string() -> None:
def test_save_turn_persists_runtime_context_and_public_view_hides_it() -> None:
loop = _mk_loop()
session = Session(key="test:suffix-strip")
guidance = ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG + "\ninternal guidance"
runtime = (
ContextBuilder._RUNTIME_CONTEXT_TAG
+ "\nCurrent Time: now\n"
+ ContextBuilder._RUNTIME_CONTEXT_END
)
suffix = f"{guidance}\n\n{runtime}"
block = RuntimeContextBlock(source="goal", content="internal goal guidance")
loop._save_turn(
session,
[_host_text_message(f"hello world\n\n{suffix}", suffix)],
[_runtime_message("hello world", [block])],
skip=0,
)
assert session.messages[0]["content"] == "hello world"
assert session.messages[0]["content"] == "hello world\n\ninternal goal guidance"
assert session.messages[0][RUNTIME_CONTEXT_HISTORY_META]["sources"] == ["goal"]
assert public_history_message(session.messages[0])["content"] == "hello world"
def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_path: Path) -> None:
@@ -432,7 +439,7 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
session = Session(key="test:user-guidance-literal")
user_text = (
"Keep this prefix\n"
f"{ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG}\n"
"[Goal Runtime Guidance — host instructions]\n"
"This label and everything after it are user-authored."
)
messages = ContextBuilder(tmp_path).build_messages(
@@ -440,10 +447,8 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
user_text,
channel="cli",
chat_id="direct",
goal_start_requested=True,
)
assert "_meta" in messages[-1]
assert "_meta" not in LLMProvider._sanitize_empty_content(messages)[-1]
assert "_meta" not in messages[-1]
loop._save_turn(session, messages, skip=1)
@@ -467,7 +472,6 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
media=[str(image)],
channel="cli",
chat_id="direct",
goal_start_requested=True,
)
loop._save_turn(session, messages, skip=1)
@@ -475,21 +479,18 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
assert {"type": "text", "text": user_text} in session.messages[0]["content"]
def test_save_turn_skips_string_user_when_only_runtime_context_suffix() -> None:
def test_save_turn_keeps_string_when_only_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:suffix-only")
runtime = (
ContextBuilder._RUNTIME_CONTEXT_TAG
+ "\nCurrent Time: now\n"
+ ContextBuilder._RUNTIME_CONTEXT_END
)
block = RuntimeContextBlock(source="test", content="provider context")
loop._save_turn(
session,
[_host_text_message(runtime, runtime)],
[_runtime_message("", [block])],
skip=0,
)
assert session.messages == []
assert session.messages[0]["content"] == "provider context"
assert public_history_message(session.messages[0])["content"] == ""
def test_save_turn_keeps_tool_results_under_16k() -> None:
@@ -847,9 +848,10 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
assert "Finish the long goal." in queued.content
session = loop.sessions.get_or_create("feishu:c-auto")
assert "Finish the long goal." in str(session.messages[0]["content"])
assert [
{k: v for k, v in m.items() if k in {"role", "content"}}
for m in session.messages
for m in map(public_history_message, session.messages)
] == [{"role": "user", "content": "start the goal"}]
second = await loop._process_message(queued, pending_queue=asyncio.Queue())
@@ -859,7 +861,7 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
session = loop.sessions.get_or_create("feishu:c-auto")
assert [
{k: v for k, v in m.items() if k in {"role", "content"}}
for m in session.messages
for m in map(public_history_message, session.messages)
] == [
{"role": "user", "content": "start the goal"},
{"role": "assistant", "content": "done"},
@@ -1399,7 +1401,8 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
assert "[Message Time:" not in non_system[0]["content"]
assert "[Message Time:" not in non_system[1]["content"]
assert non_system[2]["content"].count("subagent result") == 1
assert "Current Time:" in non_system[2]["content"]
assert "Current Time:" not in non_system[2]["content"]
assert non_system[2]["content"] == "subagent result"
loop.sessions.invalidate("cli:test")
persisted = loop.sessions.get_or_create("cli:test")