From c5048d5ab06a2a01694f3efb9cccfe06d8c8b12a Mon Sep 17 00:00:00 2001 From: shentry <111497882+shentry@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:37:38 +0800 Subject: [PATCH] fix: keep per-wakeup content out of proactive agent system prompts Both proactive wakeup paths built the system prompt by appending the conversation history dump and the triggering payload before calling build_main_agent. build_main_agent then appends the persona, skills and tool declarations to that same string, so every static block ended up behind content that changes on every single wakeup. The provider's prefix cache could never match and cron runs reported cached_tokens=None. Split the two wake prompts into a static system half and a dynamic user half, and move the conversation history and the job payload into the user turn: - cron/manager.py, the scheduled wakeup reported in the issue. - astr_agent_tool_exec.py, the background-task-result wakeup, which had the same construction and the same problem. The static wake rules stay in the system prompt, so instructions keep their system role and only the per-run parts move. The system prompt is now byte-identical across wakeups of the same session. Tests cover that the history and the payload reach req.prompt but not req.system_prompt, that two wakeups differing in both still produce an identical system prompt, and that the static rules are still present. Closes #8473 --- astrbot/core/astr_agent_tool_exec.py | 25 +++-- astrbot/core/astr_main_agent_resources.py | 20 +++- astrbot/core/cron/manager.py | 27 +++-- tests/unit/test_cron_manager.py | 123 ++++++++++++++++++++++ 4 files changed, 175 insertions(+), 20 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 2e5915bad0..0f49455f78 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -19,6 +19,8 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_main_agent_resources import ( BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT, + BACKGROUND_TASK_RESULT_WOKE_USER_PROMPT, + CONVERSATION_HISTORY_USER_PROMPT, ) from astrbot.core.cron.events import CronMessageEvent from astrbot.core.message.components import Image @@ -555,20 +557,26 @@ async def _wake_main_agent_for_background_result( conv = await _get_session_conv(event=cron_event, plugin_context=ctx) req.conversation = conv context = json.loads(conv.history) + context_dump = "" if context: req.contexts = context context_dump = req._print_friendly_context() req.contexts = [] - req.system_prompt += ( - "\n\nBellow is you and user previous conversation history:\n" - f"{context_dump}" - ) + req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT + # The task result and the conversation history differ on every wakeup, so + # they go in the user turn. Putting them in the system prompt would place + # them ahead of the persona and tool blocks that build_main_agent appends, + # invalidating the provider's prefix cache on every run. bg = json.dumps(extras["background_task_result"], ensure_ascii=False) - req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format( - background_task_result=bg - ) - req.prompt = ( + prompt_parts = [ + BACKGROUND_TASK_RESULT_WOKE_USER_PROMPT.format(background_task_result=bg) + ] + if context_dump: + prompt_parts.append( + CONVERSATION_HISTORY_USER_PROMPT.format(history=context_dump) + ) + prompt_parts.append( "Proceed according to your system instructions. " "Output using same language as previous conversation. " "If you need to deliver the result to the user immediately, " @@ -576,6 +584,7 @@ async def _wake_main_agent_for_background_result( "otherwise the user will not see the result. " "After completing your task, summarize and output your actions and results. " ) + req.prompt = "\n\n".join(prompt_parts) if not req.func_tool: req.func_tool = ToolSet() req.func_tool.add_tool( diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 5dd30806fb..e2b896c5e6 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -87,6 +87,12 @@ "Sound like a real conversation, not a Q&A system." ) +# The WOKE prompts below are split into a static system half and a dynamic user +# half on purpose. Anything that changes between two wakeups (the job payload, +# the conversation history) has to stay out of the system prompt, otherwise it +# sits in front of the persona and tool declarations that build_main_agent +# appends later and invalidates the provider's prefix cache on every run. + PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT = ( "You are an autonomous proactive agent.\n\n" "You are awakened by a scheduled cron job, not by a user message.\n" @@ -95,7 +101,10 @@ "2. Use historical conversation and memory to understand you and user's relationship, preferences, and context.\n" "3. If messaging the user: Explain WHY you are contacting them; Reference the cron task implicitly (not technical details).\n" "4. Use your available tools and skills to finish the task if needed.\n" - "5. Use `send_message_to_user` tool to send message to user if needed." + "5. Use `send_message_to_user` tool to send message to user if needed.\n" +) + +PROACTIVE_AGENT_CRON_WOKE_USER_PROMPT = ( "# CRON JOB CONTEXT\n" "The following object describes the scheduled task that triggered you:\n" "{cron_job}" @@ -109,12 +118,19 @@ "2. Use historical conversation and memory to understand you and user's relationship, preferences, and context." "3. If messaging the user: Explain WHY you are contacting them; Reference the background task implicitly (not technical details)." "4. You can use your available tools and skills to finish the task if needed.\n" - "5. Use `send_message_to_user` tool to send message to user if needed." + "5. Use `send_message_to_user` tool to send message to user if needed.\n" +) + +BACKGROUND_TASK_RESULT_WOKE_USER_PROMPT = ( "# BACKGROUND TASK CONTEXT\n" "The following object describes the background task that completed:\n" "{background_task_result}" ) +CONVERSATION_HISTORY_USER_PROMPT = ( + "Bellow is you and user previous conversation history:\n---\n{history}\n---\n" +) + # we prevent astrbot from connecting to known malicious hosts # these hosts are base64 encoded BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"} diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index 9e6feb37f6..27cc72ce7b 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -383,7 +383,9 @@ async def _woke_main_agent( build_main_agent, ) from astrbot.core.astr_main_agent_resources import ( + CONVERSATION_HISTORY_USER_PROMPT, PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT, + PROACTIVE_AGENT_CRON_WOKE_USER_PROMPT, ) from astrbot.core.tools.message_tools import SendMessageToUserTool @@ -429,26 +431,31 @@ async def _woke_main_agent( req.conversation = conv # finetine the messages context = json.loads(conv.history) + context_dump = "" if context: req.contexts = context context_dump = req._print_friendly_context() req.contexts = [] - req.system_prompt += ( - "\n\nBellow is you and user previous conversation history:\n" - f"---\n" - f"{context_dump}\n" - f"---\n" - ) + req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT + # The job payload and the conversation history differ on every wakeup, so + # they go in the user turn. Putting them in the system prompt would place + # them ahead of the persona and tool blocks that build_main_agent appends, + # invalidating the provider's prefix cache on every run. cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False) - req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format( - cron_job=cron_job_str - ) - req.prompt = ( + prompt_parts = [ + PROACTIVE_AGENT_CRON_WOKE_USER_PROMPT.format(cron_job=cron_job_str) + ] + if context_dump: + prompt_parts.append( + CONVERSATION_HISTORY_USER_PROMPT.format(history=context_dump) + ) + prompt_parts.append( "You are now responding to a scheduled task. " "Proceed according to your system instructions. " "Output using same language as previous conversation. " "After completing your task, summarize and output your actions and results." ) + req.prompt = "\n\n".join(prompt_parts) if delivery_session_str: if not req.func_tool: req.func_tool = ToolSet() diff --git a/tests/unit/test_cron_manager.py b/tests/unit/test_cron_manager.py index 242312754b..ccc686d9c8 100644 --- a/tests/unit/test_cron_manager.py +++ b/tests/unit/test_cron_manager.py @@ -1,5 +1,6 @@ """Tests for CronJobManager.""" +import json from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch from zoneinfo import ZoneInfo @@ -623,3 +624,125 @@ def test_get_next_run_time_nonexistent(self, cron_manager): next_run = cron_manager._get_next_run_time("non-existent") assert next_run is None + + +class TestWokeMainAgentPromptCaching: + """Tests that a cron wakeup keeps per-run content out of the system prompt. + + Anything that changes between two wakeups must live in the user turn. If it + sits in the system prompt it precedes the persona and tool blocks that + build_main_agent appends, which invalidates the provider's prefix cache. + """ + + async def _capture_request(self, cron_manager, mock_context, *, history, cron_job): + """Run a wakeup and return the ProviderRequest handed to build_main_agent.""" + cron_manager.ctx = mock_context + captured = {} + + async def fake_build_main_agent(*, event, plugin_context, config, req): + captured["req"] = req + return None + + conv = MagicMock() + conv.history = json.dumps(history) + conv.persona_id = None + + with ( + patch( + "astrbot.core.astr_main_agent.build_main_agent", + side_effect=fake_build_main_agent, + ), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + new=AsyncMock(return_value=conv), + ), + ): + await cron_manager._woke_main_agent( + message="scheduled", + session_str="cron:FriendMessage:session123", + extras={"cron_job": cron_job}, + ) + + assert "req" in captured, "build_main_agent was never reached" + return captured["req"] + + @pytest.mark.asyncio + async def test_history_goes_to_user_prompt_not_system_prompt( + self, cron_manager, mock_context + ): + """Test conversation history is carried by the user turn.""" + req = await self._capture_request( + cron_manager, + mock_context, + history=[{"role": "user", "content": "remember my cat Mimi"}], + cron_job={"id": "job-1"}, + ) + + assert "Mimi" not in req.system_prompt + assert "Mimi" in req.prompt + + @pytest.mark.asyncio + async def test_cron_job_payload_goes_to_user_prompt_not_system_prompt( + self, cron_manager, mock_context + ): + """Test the triggering job payload is carried by the user turn.""" + req = await self._capture_request( + cron_manager, + mock_context, + history=[], + cron_job={"id": "job-1", "name": "water-the-plants"}, + ) + + assert "water-the-plants" not in req.system_prompt + assert "water-the-plants" in req.prompt + + @pytest.mark.asyncio + async def test_system_prompt_identical_across_wakeups( + self, cron_manager, mock_context + ): + """Test two wakeups that differ in history and payload share a system prompt.""" + first = await self._capture_request( + cron_manager, + mock_context, + history=[{"role": "user", "content": "first conversation"}], + cron_job={"id": "job-1", "name": "morning"}, + ) + second = await self._capture_request( + cron_manager, + mock_context, + history=[{"role": "user", "content": "a totally different chat"}], + cron_job={"id": "job-2", "name": "evening"}, + ) + + assert first.system_prompt == second.system_prompt + assert first.prompt != second.prompt + + @pytest.mark.asyncio + async def test_system_prompt_keeps_static_wake_rules( + self, cron_manager, mock_context + ): + """Test the static wake instructions still reach the system prompt.""" + req = await self._capture_request( + cron_manager, + mock_context, + history=[], + cron_job={"id": "job-1"}, + ) + + assert "You are an autonomous proactive agent." in req.system_prompt + assert "This is NOT a chat turn." in req.system_prompt + + @pytest.mark.asyncio + async def test_no_history_block_when_conversation_is_empty( + self, cron_manager, mock_context + ): + """Test the history section is omitted entirely for a fresh conversation.""" + req = await self._capture_request( + cron_manager, + mock_context, + history=[], + cron_job={"id": "job-1"}, + ) + + assert "previous conversation history" not in req.prompt + assert "previous conversation history" not in req.system_prompt