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