feat(agent): add persistent runtime context providers
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user