diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 088d4c5..a771981 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -21,6 +21,7 @@ class ContextBuilder: """ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] + _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" def __init__(self, workspace: Path): self.workspace = workspace @@ -105,21 +106,14 @@ Reply directly with text for conversations. Only use the 'message' tool to send - Recall past events: grep {workspace_path}/memory/HISTORY.md""" @staticmethod - def _inject_runtime_context( - user_content: str | list[dict[str, Any]], - channel: str | None, - chat_id: str | None, - ) -> str | list[dict[str, Any]]: - """Append dynamic runtime context to the tail of the user message.""" + def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: + """Build untrusted runtime metadata block for injection before the user message.""" now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") tz = time.strftime("%Z") or "UTC" lines = [f"Current Time: {now} ({tz})"] if channel and chat_id: lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] - block = "[Runtime Context]\n" + "\n".join(lines) - if isinstance(user_content, str): - return f"{user_content}\n\n{block}" - return [*user_content, {"type": "text", "text": block}] + return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" @@ -165,9 +159,11 @@ Reply directly with text for conversations. Only use the 'message' tool to send # History messages.extend(history) - # Current message (with optional image attachments) + # Inject runtime metadata as a separate user message before the actual user message. + messages.append({"role": "user", "content": self._build_runtime_context(channel, chat_id)}) + + # Current user message user_content = self._build_user_content(current_message, media) - user_content = self._inject_runtime_context(user_content, channel, chat_id) messages.append({"role": "user", "content": user_content}) return messages diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 8e2333c..9afcc7d 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -39,8 +39,8 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> assert prompt1 == prompt2 -def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: - """Dynamic runtime details should be added at the tail user message, not system.""" +def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: + """Runtime metadata should be a separate user message before the actual user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -54,10 +54,13 @@ def test_runtime_context_is_appended_to_current_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] + assert messages[-2]["role"] == "user" + runtime_content = messages[-2]["content"] + assert isinstance(runtime_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content + assert "Current Time:" in runtime_content + assert "Channel: cli" in runtime_content + assert "Chat ID: direct" in runtime_content + assert messages[-1]["role"] == "user" - user_content = messages[-1]["content"] - assert isinstance(user_content, str) - assert "Return exactly: OK" in user_content - assert "Current Time:" in user_content - assert "Channel: cli" in user_content - assert "Chat ID: direct" in user_content + assert messages[-1]["content"] == "Return exactly: OK"