mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
perf(agent): append runtime context after user content for cache stability
Runtime context (time, channel, sender) changes every turn, so placing it before user content invalidated the prompt-cache prefix. Appending it after user content keeps the prefix stable and improves KV cache hit rates. The stripping logic in _save_turn was simplified from 16 lines to 6 as a side benefit.
This commit is contained in:
parent
164614ccf2
commit
0f3677c0d8
@ -93,7 +93,7 @@ class ContextBuilder:
|
|||||||
channel: str | None, chat_id: str | None, timezone: str | None = None,
|
channel: str | None, chat_id: str | None, timezone: str | None = None,
|
||||||
sender_id: str | None = None,
|
sender_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build untrusted runtime metadata block for injection before the user message."""
|
"""Build untrusted runtime metadata block appended after user content."""
|
||||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
lines = [f"Current Time: {current_time_str(timezone)}"]
|
||||||
if channel and chat_id:
|
if channel and chat_id:
|
||||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
||||||
@ -154,10 +154,12 @@ class ContextBuilder:
|
|||||||
|
|
||||||
# Merge runtime context and user content into a single user message
|
# Merge runtime context and user content into a single user message
|
||||||
# to avoid consecutive same-role messages that some providers reject.
|
# to avoid consecutive same-role messages that some providers reject.
|
||||||
|
# Runtime context is appended to keep the user-content prefix stable
|
||||||
|
# for prompt-cache hits (the context changes every turn due to time).
|
||||||
if isinstance(user_content, str):
|
if isinstance(user_content, str):
|
||||||
merged = f"{runtime_ctx}\n\n{user_content}"
|
merged = f"{user_content}\n\n{runtime_ctx}"
|
||||||
else:
|
else:
|
||||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary)},
|
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary)},
|
||||||
*history,
|
*history,
|
||||||
|
|||||||
@ -720,9 +720,9 @@ class AgentLoop:
|
|||||||
self.context.timezone,
|
self.context.timezone,
|
||||||
)
|
)
|
||||||
if isinstance(user_content, str):
|
if isinstance(user_content, str):
|
||||||
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
|
merged: str | list[dict[str, Any]] = f"{user_content}\n\n{runtime_ctx}"
|
||||||
else:
|
else:
|
||||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
||||||
return {"role": "user", "content": merged}
|
return {"role": "user", "content": merged}
|
||||||
|
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
@ -1443,24 +1443,14 @@ class AgentLoop:
|
|||||||
continue
|
continue
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
|
if isinstance(content, str) and ContextBuilder._RUNTIME_CONTEXT_TAG in content:
|
||||||
# Strip the entire runtime-context block (including any session summary).
|
# Strip the runtime-context block appended at the end.
|
||||||
# The block is bounded by _RUNTIME_CONTEXT_TAG and _RUNTIME_CONTEXT_END.
|
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG)
|
||||||
end_marker = ContextBuilder._RUNTIME_CONTEXT_END
|
before = content[:tag_pos].rstrip("\n ")
|
||||||
end_pos = content.find(end_marker)
|
if before:
|
||||||
if end_pos >= 0:
|
entry["content"] = before
|
||||||
after = content[end_pos + len(end_marker):].lstrip("\n")
|
|
||||||
if after:
|
|
||||||
entry["content"] = after
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
# Fallback: no end marker found, strip the tag prefix
|
continue
|
||||||
after_tag = content[len(ContextBuilder._RUNTIME_CONTEXT_TAG):].lstrip("\n")
|
|
||||||
if after_tag.strip():
|
|
||||||
entry["content"] = after_tag
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
|
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
|
||||||
if not filtered:
|
if not filtered:
|
||||||
|
|||||||
@ -87,6 +87,24 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
|||||||
assert "Return exactly: OK" in user_content
|
assert "Return exactly: OK" in user_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_context_appended_after_user_content(tmp_path) -> None:
|
||||||
|
"""User content must precede runtime context for prompt-cache prefix stability."""
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
messages = builder.build_messages(
|
||||||
|
history=[],
|
||||||
|
current_message="hello world",
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = messages[-1]["content"]
|
||||||
|
user_pos = content.find("hello world")
|
||||||
|
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG)
|
||||||
|
assert user_pos < tag_pos, "user content must precede runtime context for prefix stability"
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
|
def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
|
||||||
"""Sender ID should be included in runtime context when provided."""
|
"""Sender ID should be included in runtime context when provided."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
|
|||||||
@ -101,8 +101,8 @@ def test_save_turn_keeps_image_placeholder_with_path_after_runtime_strip() -> No
|
|||||||
[{
|
[{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
{"type": "text", "text": runtime},
|
|
||||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
|
||||||
|
{"type": "text", "text": runtime},
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
skip=0,
|
skip=0,
|
||||||
@ -120,8 +120,8 @@ def test_save_turn_keeps_image_placeholder_without_meta() -> None:
|
|||||||
[{
|
[{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
{"type": "text", "text": runtime},
|
|
||||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||||
|
{"type": "text", "text": runtime},
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
skip=0,
|
skip=0,
|
||||||
@ -129,6 +129,40 @@ def test_save_turn_keeps_image_placeholder_without_meta() -> None:
|
|||||||
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
|
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_turn_strips_runtime_context_suffix_from_string() -> None:
|
||||||
|
loop = _mk_loop()
|
||||||
|
session = Session(key="test:suffix-strip")
|
||||||
|
runtime = (
|
||||||
|
ContextBuilder._RUNTIME_CONTEXT_TAG
|
||||||
|
+ "\nCurrent Time: now\n"
|
||||||
|
+ ContextBuilder._RUNTIME_CONTEXT_END
|
||||||
|
)
|
||||||
|
|
||||||
|
loop._save_turn(
|
||||||
|
session,
|
||||||
|
[{"role": "user", "content": f"hello world\n\n{runtime}"}],
|
||||||
|
skip=0,
|
||||||
|
)
|
||||||
|
assert session.messages[0]["content"] == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_turn_skips_string_user_when_only_runtime_context_suffix() -> None:
|
||||||
|
loop = _mk_loop()
|
||||||
|
session = Session(key="test:suffix-only")
|
||||||
|
runtime = (
|
||||||
|
ContextBuilder._RUNTIME_CONTEXT_TAG
|
||||||
|
+ "\nCurrent Time: now\n"
|
||||||
|
+ ContextBuilder._RUNTIME_CONTEXT_END
|
||||||
|
)
|
||||||
|
|
||||||
|
loop._save_turn(
|
||||||
|
session,
|
||||||
|
[{"role": "user", "content": runtime}],
|
||||||
|
skip=0,
|
||||||
|
)
|
||||||
|
assert session.messages == []
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_tool_results_under_16k() -> None:
|
def test_save_turn_keeps_tool_results_under_16k() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:tool-result")
|
session = Session(key="test:tool-result")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user