Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -555,27 +557,34 @@ 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, "
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
"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(
Expand Down
20 changes: 18 additions & 2 deletions astrbot/core/astr_main_agent_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}"
Expand All @@ -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"}
Expand Down
27 changes: 17 additions & 10 deletions astrbot/core/cron/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
123 changes: 123 additions & 0 deletions tests/unit/test_cron_manager.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading