From 990a28b51bb18eef04df8bc2c3f401e71dc9f488 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 27 May 2026 08:41:40 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=20Agent=20=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=8A=A5=E5=91=8A=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/__init__.py | 20 +++++++++++- app/agent/prompt/System Tasks.yaml | 4 +++ tests/test_agent_background_output.py | 47 ++++++++++++++++++++++++--- tests/test_agent_prompt_style.py | 2 ++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/agent/__init__.py b/app/agent/__init__.py index dc78b69c..85722f46 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -798,6 +798,12 @@ class MoviePilotAgent: always_include_tools = ( MoviePilotToolFactory.get_tool_selector_always_include_names(tools) ) + if self.is_heartbeat_session: + available_tool_names = { + tool.name for tool in tools if getattr(tool, "name", None) + } + if "send_message" in available_tool_names: + always_include_tools.append("send_message") # 中间件 middlewares = [ @@ -1192,6 +1198,8 @@ class _MessageTask: original_chat_id: Optional[str] = None processing_status: Optional[dict] = None reply_mode: ReplyMode = ReplyMode.DISPATCH + persist_output_message: bool = True + allow_message_tools: bool = True class AgentManager: @@ -1335,6 +1343,8 @@ class AgentManager: original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None, reply_mode: ReplyMode = ReplyMode.DISPATCH, + persist_output_message: bool = True, + allow_message_tools: bool = True, ) -> str: """ 处理用户消息:将消息放入会话队列,按顺序依次处理。 @@ -1352,6 +1362,8 @@ class AgentManager: original_message_id=original_message_id, original_chat_id=original_chat_id, reply_mode=reply_mode, + persist_output_message=persist_output_message, + allow_message_tools=allow_message_tools, ) self._record_session_activity(session_id, user_id) @@ -1461,6 +1473,8 @@ class AgentManager: original_message_id=task.original_message_id, original_chat_id=task.original_chat_id, replay_mode=task.reply_mode, + persist_output_message=task.persist_output_message, + allow_message_tools=task.allow_message_tools, ) self.active_agents[session_id] = agent else: @@ -1475,6 +1489,8 @@ class AgentManager: agent.original_message_id = task.original_message_id agent.original_chat_id = task.original_chat_id agent.reply_mode = task.reply_mode + agent.persist_output_message = task.persist_output_message + agent.allow_message_tools = task.allow_message_tools return await agent.process(task.message, images=task.images, files=task.files) @@ -1612,7 +1628,9 @@ class AgentManager: channel=None, source=None, username=settings.SUPERUSER, - reply_mode=ReplyMode.DISPATCH, + reply_mode=ReplyMode.CAPTURE_ONLY, + persist_output_message=False, + allow_message_tools=True, ) # 等待消息队列处理完成 diff --git a/app/agent/prompt/System Tasks.yaml b/app/agent/prompt/System Tasks.yaml index efa14ade..0625808d 100644 --- a/app/agent/prompt/System Tasks.yaml +++ b/app/agent/prompt/System Tasks.yaml @@ -14,7 +14,11 @@ task_types: - "For 'recurring' jobs, check 'last_run' to determine if it's time to run again." - "For 'once' jobs with status 'pending', execute them now." - "After executing each job, update its status, 'last_run' time, and execution log in the JOB.md file." + - "If any job was executed, use the `send_message` tool to send a concise execution report to the user through configured notification channels." empty_result: "If no jobs were executed, output nothing." + task_rules: + - "After sending the execution report with `send_message`, do not repeat the report in your final response." + - "Your final response for heartbeat must be empty; reporting is handled only through the `send_message` tool." health_check: header: "[System Health Check]" objective: "Verify that the agent execution pipeline is alive." diff --git a/tests/test_agent_background_output.py b/tests/test_agent_background_output.py index 6bff97e0..d68187c5 100644 --- a/tests/test_agent_background_output.py +++ b/tests/test_agent_background_output.py @@ -252,7 +252,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): save_messages.assert_called_once() self.assertEqual("后台结果", agent._streamed_output) - async def test_heartbeat_check_jobs_uses_dispatch_reply_mode(self): + async def test_heartbeat_check_jobs_captures_final_reply_and_keeps_message_tools(self): manager = AgentManager() with ( @@ -271,10 +271,10 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): await manager.heartbeat_check_jobs() process_message.assert_awaited_once() - self.assertEqual( - ReplyMode.DISPATCH, - process_message.await_args.kwargs["reply_mode"], - ) + kwargs = process_message.await_args.kwargs + self.assertEqual(ReplyMode.CAPTURE_ONLY, kwargs["reply_mode"]) + self.assertFalse(kwargs["persist_output_message"]) + self.assertTrue(kwargs["allow_message_tools"]) async def test_heartbeat_check_jobs_skips_when_no_active_jobs(self): manager = AgentManager() @@ -320,6 +320,43 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): created["middleware"], ) + async def test_heartbeat_session_always_keeps_send_message_tool(self): + agent = MoviePilotAgent( + session_id=f"{HEARTBEAT_SESSION_PREFIX}test__", + user_id="system", + ) + send_message_tool = SimpleNamespace(name="send_message") + agent._initialize_tools = lambda: [send_message_tool] + + captured = {} + + def fake_tool_selector(*args, **kwargs): + captured["always_include"] = kwargs["always_include"] + return "selector" + + with ( + patch.object(settings, "LLM_MAX_TOOLS", 1), + patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())), + patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"), + patch( + "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", + return_value=[], + ), + patch("app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: "skills"), + patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), + patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"), + patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), + patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"), + patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"), + patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), + patch("app.agent.ToolSelectorMiddleware", side_effect=fake_tool_selector), + patch("app.agent.InMemorySaver", return_value="checkpointer"), + patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), + ): + await agent._create_agent(streaming=False) + + self.assertIn("send_message", captured["always_include"]) + async def test_create_agent_keeps_activity_log_for_normal_session(self): agent = MoviePilotAgent(session_id="normal-session", user_id="system") agent._initialize_tools = lambda: [] diff --git a/tests/test_agent_prompt_style.py b/tests/test_agent_prompt_style.py index f77e6aa6..d2601b17 100644 --- a/tests/test_agent_prompt_style.py +++ b/tests/test_agent_prompt_style.py @@ -172,6 +172,8 @@ class TestAgentPromptStyle(unittest.TestCase): self.assertIn("[System Heartbeat]", message) self.assertIn("List all jobs with status 'pending' or 'in_progress'.", message) self.assertIn("Do NOT include greetings, explanations, or conversational text.", message) + self.assertIn("use the `send_message` tool", message) + self.assertIn("Your final response for heartbeat must be empty", message) self.assertIn("If no jobs were executed, output nothing.", message) def test_render_system_task_message_renders_template_context(self):