refine agent subscription defaults and silent tool prompts

This commit is contained in:
jxxghp
2026-04-26 08:51:56 +08:00
parent 0277288a41
commit b5979b9b09
5 changed files with 193 additions and 55 deletions
+1
View File
@@ -60,6 +60,7 @@ Core Capabilities:
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults. 2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
3. Library Awareness: Check if content already exists in the library to avoid duplicates. 3. Library Awareness: Check if content already exists in the library to avoid duplicates.
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative. 4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
5. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
</media_management_rules> </media_management_rules>
<markdown_spec> <markdown_spec>
+12 -10
View File
@@ -82,11 +82,13 @@ class PromptManager:
verbose_spec = "" verbose_spec = ""
if not settings.AI_AGENT_VERBOSE: if not settings.AI_AGENT_VERBOSE:
verbose_spec = ( verbose_spec = (
"\n\n[Important Instruction] STRICTLY ENFORCED: DO NOT output any conversational " "\n\n[Important Instruction] STRICTLY ENFORCED WHEN VERBOSE MODE IS OFF: "
"text, thinking processes, or explanations before or during tool calls. Call tools " "If tools are needed, DO NOT output any conversational text, explanations, progress updates, "
"directly without any transitional phrases. " "or acknowledgements before the first tool call or between tool calls. "
"You MUST remain completely silent until the task is completely finished. " "Call tools directly without any transitional phrases. "
"DO NOT output any content whatsoever until your final summary reply." "You MUST remain completely silent until all required tools have finished and you have the final result. "
"Only then may you send one final user-facing reply. "
"DO NOT output any intermediate content whatsoever."
) )
# MoviePilot系统信息 # MoviePilot系统信息
@@ -193,18 +195,18 @@ class PromptManager:
def _generate_button_choice_instructions( def _generate_button_choice_instructions(
channel: MessageChannel = None, channel: MessageChannel = None,
) -> str: ) -> str:
if channel and ChannelCapabilityManager.supports_buttons( if (
channel channel
) and ChannelCapabilityManager.supports_callbacks(channel): and ChannelCapabilityManager.supports_buttons(channel)
and ChannelCapabilityManager.supports_callbacks(channel)
):
return ( return (
"- User questions: If you need the user to choose from a few clear options, " "- User questions: If you need the user to choose from a few clear options, "
"call `ask_user_choice` to send button options. After the user clicks a button, " "call `ask_user_choice` to send button options. After the user clicks a button, "
"the selected value will come back as the user's next message. After calling this tool, " "the selected value will come back as the user's next message. After calling this tool, "
"wait for the user's selection instead of repeating the question in plain text." "wait for the user's selection instead of repeating the question in plain text."
) )
return ( return "- User questions: When you truly need user input, ask briefly in plain text."
"- User questions: When you truly need user input, ask briefly in plain text."
)
def clear_cache(self): def clear_cache(self):
""" """
+109 -43
View File
@@ -12,36 +12,74 @@ from app.schemas.types import MediaType
class AddSubscribeInput(BaseModel): class AddSubscribeInput(BaseModel):
"""添加订阅工具的输入参数模型""" """添加订阅工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
title: str = Field(..., description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')") explanation: str = Field(
year: str = Field(..., description="Release year of the media (required for accurate identification)") ...,
media_type: str = Field(..., description="Clear explanation of why this tool is being used in the current context",
description="Allowed values: movie, tv") )
season: Optional[int] = Field(None, title: str = Field(
description="Season number for TV shows (optional, if not specified will subscribe to all seasons)") ...,
tmdb_id: Optional[int] = Field(None, description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)") )
douban_id: Optional[str] = Field(None, year: str = Field(
description="Douban ID for precise media identification (optional, alternative to tmdb_id)") ...,
start_episode: Optional[int] = Field(None, description="Release year of the media (required for accurate identification)",
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)") )
total_episode: Optional[int] = Field(None, media_type: str = Field(..., description="Allowed values: movie, tv")
description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)") season: Optional[int] = Field(
quality: Optional[str] = Field(None, None,
description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')") description=(
resolution: Optional[str] = Field(None, "Season number for TV shows (optional). If omitted, the subscription defaults to season 1 only. "
description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')") "To subscribe multiple seasons or the full series, call this tool separately for each season."
effect: Optional[str] = Field(None, ),
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')") )
filter_groups: Optional[List[str]] = Field(None, tmdb_id: Optional[int] = Field(
description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)") None,
sites: Optional[List[int]] = Field(None, description="TMDB database ID for precise media identification (optional, can be obtained from search_media tool)",
description="List of site IDs to search from (optional, can be obtained from query_sites tool)") )
douban_id: Optional[str] = Field(
None,
description="Douban ID for precise media identification (optional, alternative to tmdb_id)",
)
start_episode: Optional[int] = Field(
None,
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
)
total_episode: Optional[int] = Field(
None,
description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)",
)
quality: Optional[str] = Field(
None,
description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')",
)
resolution: Optional[str] = Field(
None,
description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')",
)
effect: Optional[str] = Field(
None,
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
)
filter_groups: Optional[List[str]] = Field(
None,
description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)",
)
sites: Optional[List[int]] = Field(
None,
description="List of site IDs to search from (optional, can be obtained from query_sites tool)",
)
class AddSubscribeTool(MoviePilotTool): class AddSubscribeTool(MoviePilotTool):
name: str = "add_subscribe" name: str = "add_subscribe"
description: str = "Add media subscription to create automated download rules for movies and TV shows. The system will automatically search and download new episodes or releases based on the subscription criteria. Supports advanced filtering options like quality, resolution, and effect filters using regular expressions." description: str = (
"Add media subscription to create automated download rules for movies and TV shows. "
"The system will automatically search and download new episodes or releases based on the subscription criteria. "
"For TV shows, omitting `season` subscribes season 1 only by default; to subscribe multiple seasons or "
"the full series, call this tool once per season. Supports advanced filtering options like quality, "
"resolution, and effect filters using regular expressions."
)
args_schema: Type[BaseModel] = AddSubscribeInput args_schema: Type[BaseModel] = AddSubscribeInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -58,44 +96,64 @@ class AddSubscribeTool(MoviePilotTool):
message += f" [{media_type}]" message += f" [{media_type}]"
if season: if season:
message += f"{season}" message += f"{season}"
elif media_type == "tv":
message += " 第1季(默认)"
return message return message
async def run(self, title: str, year: str, media_type: str, async def run(
season: Optional[int] = None, tmdb_id: Optional[int] = None, self,
douban_id: Optional[str] = None, title: str,
start_episode: Optional[int] = None, total_episode: Optional[int] = None, year: str,
quality: Optional[str] = None, resolution: Optional[str] = None, media_type: str,
effect: Optional[str] = None, filter_groups: Optional[List[str]] = None, season: Optional[int] = None,
sites: Optional[List[int]] = None, **kwargs) -> str: tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
start_episode: Optional[int] = None,
total_episode: Optional[int] = None,
quality: Optional[str] = None,
resolution: Optional[str] = None,
effect: Optional[str] = None,
filter_groups: Optional[List[str]] = None,
sites: Optional[List[int]] = None,
**kwargs,
) -> str:
logger.info( logger.info(
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, " f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
f"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, " f"season={season}, tmdb_id={tmdb_id}, douban_id={douban_id}, start_episode={start_episode}, "
f"total_episode={total_episode}, quality={quality}, resolution={resolution}, " f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
f"effect={effect}, filter_groups={filter_groups}, sites={sites}") f"effect={effect}, filter_groups={filter_groups}, sites={sites}"
)
try: try:
subscribe_chain = SubscribeChain() subscribe_chain = SubscribeChain()
media_type_enum = MediaType.from_agent(media_type) media_type_enum = MediaType.from_agent(media_type)
if not media_type_enum: if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'" return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
effective_season = (
season
if season is not None
else 1
if media_type_enum == MediaType.TV
else None
)
# 构建额外的订阅参数 # 构建额外的订阅参数
subscribe_kwargs = {} subscribe_kwargs = {}
if start_episode is not None: if start_episode is not None:
subscribe_kwargs['start_episode'] = start_episode subscribe_kwargs["start_episode"] = start_episode
if total_episode is not None: if total_episode is not None:
subscribe_kwargs['total_episode'] = total_episode subscribe_kwargs["total_episode"] = total_episode
if quality: if quality:
subscribe_kwargs['quality'] = quality subscribe_kwargs["quality"] = quality
if resolution: if resolution:
subscribe_kwargs['resolution'] = resolution subscribe_kwargs["resolution"] = resolution
if effect: if effect:
subscribe_kwargs['effect'] = effect subscribe_kwargs["effect"] = effect
if filter_groups: if filter_groups:
subscribe_kwargs['filter_groups'] = filter_groups subscribe_kwargs["filter_groups"] = filter_groups
if sites: if sites:
subscribe_kwargs['sites'] = sites subscribe_kwargs["sites"] = sites
sid, message = await subscribe_chain.async_add( sid, message = await subscribe_chain.async_add(
mtype=media_type_enum, mtype=media_type_enum,
@@ -105,13 +163,21 @@ class AddSubscribeTool(MoviePilotTool):
doubanid=douban_id, doubanid=douban_id,
season=season, season=season,
username=self._user_id, username=self._user_id,
**subscribe_kwargs **subscribe_kwargs,
) )
if sid: if sid:
if message and "已存在" in message: if message and "已存在" in message:
return f"订阅已存在:{title} ({year})。如需修改参数请先删除旧订阅。" result_msg = f"订阅已存在:{title} ({year})"
if effective_season is not None:
result_msg += f"{effective_season}"
result_msg += "。如需修改参数请先删除旧订阅。"
return result_msg
result_msg = f"成功添加订阅:{title} ({year})" result_msg = f"成功添加订阅:{title} ({year})"
if effective_season is not None:
result_msg += f"{effective_season}"
if season is None:
result_msg += "(未指定季号,默认按第一季订阅)"
if subscribe_kwargs: if subscribe_kwargs:
params = [] params = []
if start_episode is not None: if start_episode is not None:
+29
View File
@@ -0,0 +1,29 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, patch
from app.agent.tools.impl.add_subscribe import AddSubscribeTool
class TestAgentAddSubscribeTool(unittest.TestCase):
def test_tv_subscription_without_season_reports_default_first_season(self):
tool = AddSubscribeTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.add_subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "")),
):
result = asyncio.run(
tool.run(
title="Breaking Bad",
year="2008",
media_type="tv",
)
)
self.assertIn("第1季", result)
self.assertIn("默认按第一季订阅", result)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -1,7 +1,9 @@
import unittest import unittest
from unittest.mock import patch
from app.agent.middleware.memory import MEMORY_ONBOARDING_PROMPT from app.agent.middleware.memory import MEMORY_ONBOARDING_PROMPT
from app.agent.prompt import prompt_manager from app.agent.prompt import prompt_manager
from app.core.config import settings
class TestAgentPromptStyle(unittest.TestCase): class TestAgentPromptStyle(unittest.TestCase):
@@ -12,6 +14,44 @@ class TestAgentPromptStyle(unittest.TestCase):
self.assertIn("Do NOT flatter the user", prompt) self.assertIn("Do NOT flatter the user", prompt)
self.assertIn("NO praise, emotional cushioning", prompt) self.assertIn("NO praise, emotional cushioning", prompt)
def test_agent_prompt_defines_tv_subscription_default_season_rule(self):
prompt = prompt_manager.get_agent_prompt()
self.assertIn(
"omitting `season` means subscribe to season 1 only",
prompt,
)
self.assertIn(
"call `add_subscribe` separately for each season",
prompt,
)
def test_non_verbose_prompt_requires_silence_until_all_tools_finish(self):
with patch.object(settings, "AI_AGENT_VERBOSE", False):
prompt = prompt_manager.get_agent_prompt()
self.assertIn(
"STRICTLY ENFORCED WHEN VERBOSE MODE IS OFF",
prompt,
)
self.assertIn(
"DO NOT output any conversational text, explanations, progress updates, or acknowledgements before the first tool call or between tool calls",
prompt,
)
self.assertIn(
"Only then may you send one final user-facing reply",
prompt,
)
def test_verbose_prompt_does_not_inject_silence_until_tools_finish_rule(self):
with patch.object(settings, "AI_AGENT_VERBOSE", True):
prompt = prompt_manager.get_agent_prompt()
self.assertNotIn(
"STRICTLY ENFORCED WHEN VERBOSE MODE IS OFF",
prompt,
)
def test_memory_onboarding_does_not_force_warm_intro(self): def test_memory_onboarding_does_not_force_warm_intro(self):
self.assertIn("Do NOT interrupt the current task", MEMORY_ONBOARDING_PROMPT) self.assertIn("Do NOT interrupt the current task", MEMORY_ONBOARDING_PROMPT)
self.assertIn("Do NOT proactively greet warmly", MEMORY_ONBOARDING_PROMPT) self.assertIn("Do NOT proactively greet warmly", MEMORY_ONBOARDING_PROMPT)