diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index d0ef33b815..84a97ea5f1 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -112,7 +112,7 @@ 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" - "You are given:" + "You are given:\n" "1. A cron job description explaining why you are activated.\n" "2. Historical conversation context between you and the user.\n" "3. Your available tools and skills.\n" @@ -121,7 +121,7 @@ "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. 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. IMPORTANT: Your text output is NOT visible to the user. The ONLY way to deliver a message to the user is by calling the `send_message_to_user` tool. You MUST call this tool to send any message — do NOT just generate text.\n" "# CRON JOB CONTEXT\n" "The following object describes the scheduled task that triggered you:\n" "{cron_job}" diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index 25a3a219cf..14ea193446 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -332,10 +332,11 @@ async def _woke_main_agent( cron_job=cron_job_str ) req.prompt = ( - "You are now responding to a scheduled task. " + "A scheduled task has been triggered. " "Proceed according to your system instructions. " - "Output using same language as previous conversation. " - "After completing your task, summarize and output your actions and results." + "You MUST call the `send_message_to_user` tool to deliver any message to the user. " + "Your direct text response is NOT visible to the user — only tool calls take effect. " + "Use the same language as the previous conversation." ) if not req.func_tool: req.func_tool = ToolSet() @@ -353,25 +354,59 @@ async def _woke_main_agent( # agent will send message to user via using tools pass llm_resp = runner.get_final_llm_resp() + cron_meta = extras.get("cron_job", {}) if extras else {} - summary_note = ( - f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} " - f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, " - ) - if llm_resp and llm_resp.role == "assistant": - summary_note += ( - f"I finished this job, here is the result: {llm_resp.completion_text}" + cron_job_label = cron_meta.get("name") or cron_meta.get("id", "unknown") + + if not llm_resp: + logger.warning("Cron job [%s] agent got no response", cron_job_label) + + # 选择工具调用的名字作为日志输出,方便后续分析 cron 任务是否正确触达用户,以及用户收到的内容是什么 + called_tool_names: list[str] = [] + for msg in runner.run_context.messages: + # 只统计 role="assistant" 的消息中的工具调用 + if msg.role == "assistant" and msg.tool_calls: + # 工具调用应该是dict或者ToolCall对象的列表,兼容两者的情况 + for tc in msg.tool_calls: + if isinstance(tc, dict): + name = tc.get("function", {}).get("name") + else: + name = tc.function.name + if name: + called_tool_names.append(name) + + if not called_tool_names: + logger.warning( + "Cron job [%s] agent did not call any tools. " + "The message was likely NOT delivered to the user.", + cron_job_label, ) + tools_str = ( + f"tools called: [{', '.join(called_tool_names)}]. " + if called_tool_names + else "no tools called. " + ) + # 根据 llm_resp 判断状态:无响应、正常完成、错误终止 + if not llm_resp: + status = "task ended with no LLM response." + elif llm_resp.role == "assistant": + status = "task completed successfully." + else: + status = f"task ended with error: {llm_resp.completion_text}" + summary_note = ( + f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: " + f"{cron_meta.get('description', '')} " + f"triggered at {cron_meta.get('run_started_at', 'unknown time')}, " + f"{tools_str}" + f"{status}" + ) await persist_agent_history( self.ctx.conversation_manager, event=cron_event, req=req, summary_note=summary_note, ) - if not llm_resp: - logger.warning("Cron job agent got no response") - return __all__ = ["CronJobManager"] diff --git a/tests/unit/test_cron_manager.py b/tests/unit/test_cron_manager.py index b111384ac9..88fb978bf8 100644 --- a/tests/unit/test_cron_manager.py +++ b/tests/unit/test_cron_manager.py @@ -152,7 +152,9 @@ async def test_add_basic_job_disabled(self, cron_manager, mock_db, sample_cron_j assert sample_cron_job.job_id in cron_manager._basic_handlers @pytest.mark.asyncio - async def test_add_basic_job_with_timezone(self, cron_manager, mock_db, sample_cron_job): + async def test_add_basic_job_with_timezone( + self, cron_manager, mock_db, sample_cron_job + ): """Test adding a basic job with timezone.""" mock_db.create_cron_job.return_value = sample_cron_job @@ -189,7 +191,9 @@ async def test_add_active_job(self, cron_manager, mock_db, sample_cron_job): mock_db.create_cron_job.assert_called_once() @pytest.mark.asyncio - async def test_add_active_job_run_once(self, cron_manager, mock_db, sample_cron_job): + async def test_add_active_job_run_once( + self, cron_manager, mock_db, sample_cron_job + ): """Test adding a run-once active job.""" sample_cron_job.job_type = "active_agent" sample_cron_job.run_once = True @@ -291,7 +295,9 @@ async def test_sync_from_db_empty(self, cron_manager, mock_db): mock_db.list_cron_jobs.assert_called_once() @pytest.mark.asyncio - async def test_sync_from_db_skips_disabled(self, cron_manager, mock_db, sample_cron_job): + async def test_sync_from_db_skips_disabled( + self, cron_manager, mock_db, sample_cron_job + ): """Test that sync skips disabled jobs.""" sample_cron_job.enabled = False mock_db.list_cron_jobs.return_value = [sample_cron_job] @@ -303,7 +309,9 @@ async def test_sync_from_db_skips_disabled(self, cron_manager, mock_db, sample_c mock_schedule.assert_not_called() @pytest.mark.asyncio - async def test_sync_from_db_skips_non_persistent(self, cron_manager, mock_db, sample_cron_job): + async def test_sync_from_db_skips_non_persistent( + self, cron_manager, mock_db, sample_cron_job + ): """Test that sync skips non-persistent jobs.""" sample_cron_job.persistent = False mock_db.list_cron_jobs.return_value = [sample_cron_job] @@ -361,7 +369,9 @@ class TestScheduleJob: """Tests for _schedule_job method.""" @pytest.mark.asyncio - async def test_schedule_job_basic(self, cron_manager, sample_cron_job, mock_context): + async def test_schedule_job_basic( + self, cron_manager, sample_cron_job, mock_context + ): """Test scheduling a basic job.""" mock_db = cron_manager.db mock_db.list_cron_jobs = AsyncMock(return_value=[]) @@ -373,7 +383,9 @@ async def test_schedule_job_basic(self, cron_manager, sample_cron_job, mock_cont assert cron_manager.scheduler.get_job("test-job-id") is not None @pytest.mark.asyncio - async def test_schedule_job_with_timezone(self, cron_manager, sample_cron_job, mock_context): + async def test_schedule_job_with_timezone( + self, cron_manager, sample_cron_job, mock_context + ): """Test scheduling a job with timezone.""" sample_cron_job.timezone = "America/New_York" mock_db = cron_manager.db @@ -385,7 +397,9 @@ async def test_schedule_job_with_timezone(self, cron_manager, sample_cron_job, m assert cron_manager.scheduler.get_job("test-job-id") is not None @pytest.mark.asyncio - async def test_schedule_job_invalid_timezone(self, cron_manager, sample_cron_job, mock_context): + async def test_schedule_job_invalid_timezone( + self, cron_manager, sample_cron_job, mock_context + ): """Test scheduling a job with invalid timezone.""" sample_cron_job.timezone = "Invalid/Timezone" mock_db = cron_manager.db @@ -485,7 +499,9 @@ class TestGetNextRunTime: """Tests for _get_next_run_time method.""" @pytest.mark.asyncio - async def test_get_next_run_time_existing_job(self, cron_manager, sample_cron_job, mock_context): + async def test_get_next_run_time_existing_job( + self, cron_manager, sample_cron_job, mock_context + ): """Test getting next run time for existing job.""" mock_db = cron_manager.db mock_db.list_cron_jobs = AsyncMock(return_value=[]) @@ -502,3 +518,374 @@ 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 + + +# ============================================================ +# TestWokeMainAgent —— 验证 _woke_main_agent 的核心行为 +# ============================================================ + + +def _make_mock_message(role: str): + """创建带 role 属性的简单 mock 消息对象。""" + m = MagicMock() + m.role = role + return m + + +def _make_runner_mock(messages: list, final_resp=None): + """构造模拟 AgentRunner,包含 run_context.messages 和 get_final_llm_resp。""" + runner = MagicMock() + run_ctx = MagicMock() + run_ctx.messages = messages + runner.run_context = run_ctx + runner.get_final_llm_resp = MagicMock(return_value=final_resp) + + async def _step_until_done(_max): + return + yield # noqa: unreachable — makes this an async generator + + runner.step_until_done = _step_until_done + return runner + + +class TestWokeMainAgentPrompt: + """验证 _woke_main_agent 构造 req.prompt 与 system_prompt 时的正确性。""" + + @pytest.mark.asyncio + async def test_prompt_contains_tool_call_instruction( + self, cron_manager, mock_context + ): + """req.prompt 必须明确要求调用 send_message_to_user,而不是输出文本。""" + cron_manager.ctx = mock_context + + captured_req = {} + + async def fake_build_main_agent(*, event, plugin_context, config, req): + captured_req["req"] = req + # 返回一个带有空 runner 的 result mock + result = MagicMock() + result.agent_runner = _make_runner_mock( + messages=[], + final_resp=MagicMock(role="assistant", completion_text=""), + ) + return result + + mock_conv = MagicMock() + mock_conv.history = "[]" + + with ( + patch( + "astrbot.core.astr_main_agent.build_main_agent", fake_build_main_agent + ), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", AsyncMock()), + ): + await cron_manager._woke_main_agent( + message="发送早安问候", + session_str="QQ:FriendMessage:123456", + extras={ + "cron_job": {"id": "test-id", "name": "morning"}, + "cron_payload": {}, + }, + ) + + req = captured_req.get("req") + assert req is not None, "build_main_agent 未被调用" + # prompt 必须明确说明文本不可见、必须用工具 + assert "send_message_to_user" in req.prompt + assert "NOT visible" in req.prompt or "not visible" in req.prompt.lower() + assert "MUST" in req.prompt or "must" in req.prompt.lower() + + @pytest.mark.asyncio + async def test_system_prompt_contains_visibility_warning( + self, cron_manager, mock_context + ): + """system_prompt 中的 PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT 必须包含可见性警告。""" + from astrbot.core.astr_main_agent_resources import ( + PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT, + ) + + # 验证 prompt 模板本身的关键内容 + rendered = PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format(cron_job="{}") + assert "NOT visible" in rendered + assert "send_message_to_user" in rendered + assert "MUST" in rendered + # CRON JOB CONTEXT 标题要有换行与前文分隔 + assert "\n# CRON JOB CONTEXT" in rendered + + def test_system_prompt_template_formatting(self): + """确保 PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT 中各序号段落不会拼接在一起。""" + from astrbot.core.astr_main_agent_resources import ( + PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT, + ) + + rendered = PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format(cron_job="{}") + # "You are given:" 后应有换行,不能直接跟 "1." + assert "You are given:\n1." in rendered, ( + "'You are given:' 后应紧跟换行再接 '1.'" + ) + + +class TestWokeMainAgentHistoryPersistence: + """验证 _woke_main_agent 仅在工具被调用时才写入历史记录。""" + + @pytest.mark.asyncio + async def test_history_persisted_when_tool_called(self, cron_manager, mock_context): + """agent 调用了工具时,应写入对话历史。""" + cron_manager.ctx = mock_context + persist_mock = AsyncMock() + + messages = [ + _make_mock_message("system"), + _make_mock_message("user"), + _make_mock_message("assistant"), + _make_mock_message("tool"), # 工具被调用 + ] + final_resp = MagicMock() + final_resp.role = "assistant" + final_resp.completion_text = "任务完成" + + mock_conv = MagicMock() + mock_conv.history = "[]" + + async def fake_build(*_, **__): + result = MagicMock() + result.agent_runner = _make_runner_mock( + messages=messages, final_resp=final_resp + ) + return result + + with ( + patch("astrbot.core.astr_main_agent.build_main_agent", fake_build), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", persist_mock), + ): + await cron_manager._woke_main_agent( + message="发送消息", + session_str="QQ:FriendMessage:123456", + extras={"cron_job": {"id": "j1", "name": "test"}, "cron_payload": {}}, + ) + + persist_mock.assert_awaited_once() + # summary_note 不应包含 LLM 原始文本(避免误导) + call_kwargs = persist_mock.call_args.kwargs + assert "task completed successfully" in call_kwargs["summary_note"] + + @pytest.mark.asyncio + async def test_history_persisted_when_tool_called_with_error( + self, cron_manager, mock_context + ): + """agent 调用了工具但 llm_resp 为错误角色时,历史仍写入,summary 包含错误信息。""" + cron_manager.ctx = mock_context + persist_mock = AsyncMock() + + # assistant 消息含工具调用 + assistant_msg = _make_mock_message("assistant") + assistant_msg.tool_calls = [{"function": {"name": "send_message_to_user"}}] + tool_msg = _make_mock_message("tool") + tool_msg.tool_calls = None + messages = [assistant_msg, tool_msg] + + error_text = "provider timeout" + final_resp = MagicMock() + final_resp.role = "err" + final_resp.completion_text = error_text + + mock_conv = MagicMock() + mock_conv.history = "[]" + + async def fake_build(*_, **__): + result = MagicMock() + result.agent_runner = _make_runner_mock( + messages=messages, final_resp=final_resp + ) + return result + + with ( + patch("astrbot.core.astr_main_agent.build_main_agent", fake_build), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", persist_mock), + ): + await cron_manager._woke_main_agent( + message="发送消息", + session_str="QQ:FriendMessage:123456", + extras={"cron_job": {"id": "j1e", "name": "test"}, "cron_payload": {}}, + ) + + persist_mock.assert_awaited_once() + call_kwargs = persist_mock.call_args.kwargs + summary = call_kwargs["summary_note"] + assert "task ended with error" in summary + assert error_text in summary + + @pytest.mark.asyncio + async def test_history_persisted_even_when_no_tool_called( + self, cron_manager, mock_context + ): + """agent 未调用任何工具的时候,历史记录应写入,summary 中包含 'no tools called'。""" + cron_manager.ctx = mock_context + persist_mock = AsyncMock() + + # 只有 system / user / assistant,没有 tool 消息 + messages = [ + _make_mock_message("system"), + _make_mock_message("user"), + _make_mock_message("assistant"), + ] + final_resp = MagicMock() + final_resp.role = "assistant" + final_resp.completion_text = "我已经完成了(但其实没发消息)" + + mock_conv = MagicMock() + mock_conv.history = "[]" + + async def fake_build(*_, **__): + result = MagicMock() + result.agent_runner = _make_runner_mock( + messages=messages, final_resp=final_resp + ) + return result + + with ( + patch("astrbot.core.astr_main_agent.build_main_agent", fake_build), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", persist_mock), + ): + await cron_manager._woke_main_agent( + message="发送消息", + session_str="QQ:FriendMessage:123456", + extras={"cron_job": {"id": "j2", "name": "test"}, "cron_payload": {}}, + ) + + persist_mock.assert_awaited_once() + call_kwargs = persist_mock.call_args.kwargs + assert "no tools called" in call_kwargs["summary_note"] + + @pytest.mark.asyncio + async def test_history_persisted_when_no_llm_resp(self, cron_manager, mock_context): + """agent 无 LLM 响应时,仍写入历史(含 tools_str),方便后续追问和诊断。""" + cron_manager.ctx = mock_context + persist_mock = AsyncMock() + + messages = [_make_mock_message("tool")] # 有工具消息,但无最终 LLM 响应 + mock_conv = MagicMock() + mock_conv.history = "[]" + + async def fake_build(*_, **__): + result = MagicMock() + result.agent_runner = _make_runner_mock(messages=messages, final_resp=None) + return result + + with ( + patch("astrbot.core.astr_main_agent.build_main_agent", fake_build), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", persist_mock), + ): + # 不应抛异常 + await cron_manager._woke_main_agent( + message="发送消息", + session_str="QQ:FriendMessage:123456", + extras={"cron_job": {"id": "j3", "name": "test"}, "cron_payload": {}}, + ) + + # 无 LLM 响应时也应写入历史 + persist_mock.assert_awaited_once() + assert "no LLM response" in persist_mock.call_args.kwargs.get( + "summary_note", "" + ) + + @pytest.mark.asyncio + async def test_warning_logged_when_no_tool_called(self, cron_manager, mock_context): + """agent 未调用工具时,应记录 warning 日志提示消息可能未送达。""" + cron_manager.ctx = mock_context + + messages = [_make_mock_message("assistant")] + final_resp = MagicMock() + final_resp.role = "assistant" + final_resp.completion_text = "只有文本" + + mock_conv = MagicMock() + mock_conv.history = "[]" + + async def fake_build(*_, **__): + result = MagicMock() + result.agent_runner = _make_runner_mock( + messages=messages, final_resp=final_resp + ) + return result + + with ( + patch("astrbot.core.astr_main_agent.build_main_agent", fake_build), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", AsyncMock()), + patch("astrbot.core.cron.manager.logger") as mock_logger, + ): + await cron_manager._woke_main_agent( + message="发送消息", + session_str="QQ:FriendMessage:123456", + extras={"cron_job": {"id": "j4", "name": "test"}, "cron_payload": {}}, + ) + + # 必须有相关警告 + warning_calls = [str(c) for c in mock_logger.warning.call_args_list] + assert any("tool" in w.lower() or "NOT" in w for w in warning_calls), ( + "未找到关于工具未被调用的 warning 日志" + ) + + @pytest.mark.asyncio + async def test_send_message_to_user_tool_in_func_tool( + self, cron_manager, mock_context + ): + """req.func_tool 必须包含 send_message_to_user 工具。""" + cron_manager.ctx = mock_context + captured_req = {} + + mock_conv = MagicMock() + mock_conv.history = "[]" + + async def fake_build(*, event, plugin_context, config, req): + captured_req["req"] = req + result = MagicMock() + result.agent_runner = _make_runner_mock( + messages=[], + final_resp=MagicMock(role="assistant", completion_text=""), + ) + return result + + with ( + patch("astrbot.core.astr_main_agent.build_main_agent", fake_build), + patch( + "astrbot.core.astr_main_agent._get_session_conv", + AsyncMock(return_value=mock_conv), + ), + patch("astrbot.core.cron.manager.persist_agent_history", AsyncMock()), + ): + await cron_manager._woke_main_agent( + message="发送消息", + session_str="QQ:FriendMessage:123456", + extras={"cron_job": {"id": "j5", "name": "test"}, "cron_payload": {}}, + ) + + req = captured_req.get("req") + assert req is not None + assert req.func_tool is not None + tool_names = [t.name for t in req.func_tool.tools] + assert "send_message_to_user" in tool_names