diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 286aa4a38..42a07afe4 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -93,7 +93,7 @@ class ContextBuilder: channel: str | None, chat_id: str | None, timezone: str | None = None, sender_id: str | None = None, ) -> 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)}"] if channel and 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 # 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): - merged = f"{runtime_ctx}\n\n{user_content}" + merged = f"{user_content}\n\n{runtime_ctx}" else: - merged = [{"type": "text", "text": runtime_ctx}] + user_content + merged = user_content + [{"type": "text", "text": runtime_ctx}] messages = [ {"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary)}, *history, diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index ad4b6d0dd..a24feb57d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -720,9 +720,9 @@ class AgentLoop: self.context.timezone, ) 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: - merged = [{"type": "text", "text": runtime_ctx}] + user_content + merged = user_content + [{"type": "text", "text": runtime_ctx}] return {"role": "user", "content": merged} items: list[dict[str, Any]] = [] @@ -1443,24 +1443,14 @@ class AgentLoop: continue entry["content"] = filtered elif role == "user": - if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG): - # Strip the entire runtime-context block (including any session summary). - # The block is bounded by _RUNTIME_CONTEXT_TAG and _RUNTIME_CONTEXT_END. - end_marker = ContextBuilder._RUNTIME_CONTEXT_END - end_pos = content.find(end_marker) - if end_pos >= 0: - after = content[end_pos + len(end_marker):].lstrip("\n") - if after: - entry["content"] = after - else: - continue + if isinstance(content, str) and ContextBuilder._RUNTIME_CONTEXT_TAG in content: + # Strip the runtime-context block appended at the end. + tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG) + before = content[:tag_pos].rstrip("\n ") + if before: + entry["content"] = before else: - # Fallback: no end marker found, strip the tag prefix - after_tag = content[len(ContextBuilder._RUNTIME_CONTEXT_TAG):].lstrip("\n") - if after_tag.strip(): - entry["content"] = after_tag - else: - continue + continue if isinstance(content, list): filtered = self._sanitize_persisted_blocks(content, drop_runtime=True) if not filtered: diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index 6e69dc85b..4b6f3dadf 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -87,6 +87,24 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: 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: """Sender ID should be included in runtime context when provided.""" workspace = _make_workspace(tmp_path) diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 36b133999..35b00474b 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -101,8 +101,8 @@ def test_save_turn_keeps_image_placeholder_with_path_after_runtime_strip() -> No [{ "role": "user", "content": [ - {"type": "text", "text": runtime}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}}, + {"type": "text", "text": runtime}, ], }], skip=0, @@ -120,8 +120,8 @@ def test_save_turn_keeps_image_placeholder_without_meta() -> None: [{ "role": "user", "content": [ - {"type": "text", "text": runtime}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + {"type": "text", "text": runtime}, ], }], 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]"}] +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: loop = _mk_loop() session = Session(key="test:tool-result")