feat(agent): add ToolTag-based tags to all agent tools; implement tags.py for unified tool capability tagging

This commit is contained in:
jxxghp
2026-05-31 18:30:39 +08:00
parent 855681ff35
commit c89df496a5
87 changed files with 1212 additions and 6 deletions

View File

@@ -36,6 +36,11 @@ from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
from app.agent.middleware.skills import SkillsMiddleware
from app.agent.middleware.subagents import (
SUBAGENT_TASK_TOOL_NAME,
create_subagent_middlewares,
is_subagent_stream_metadata,
)
from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
@@ -774,6 +779,25 @@ class MoviePilotAgent:
allow_message_tools=self.allow_message_tools,
)
def _initialize_subagent_tools(self) -> List:
"""
初始化子代理专用静默工具列表。
"""
return MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
channel=self.channel,
source=self.source,
username=self.username,
stream_handler=None,
agent_context={
"user_reply_sent": False,
"reply_mode": None,
"should_dispatch_reply": False,
},
allow_message_tools=False,
)
async def _create_agent(self, streaming: bool = False):
"""
创建 LangGraph Agent使用 create_agent + SummarizationMiddleware
@@ -796,10 +820,21 @@ class MoviePilotAgent:
# 工具列表
tools = self._initialize_tools()
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
tools=self._initialize_subagent_tools(),
stream_handler=self.stream_handler,
)
max_tools = settings.LLM_MAX_TOOLS
always_include_tools = (
MoviePilotToolFactory.get_tool_selector_always_include_names(tools)
)
if subagent_task_tools:
always_include_tools.extend(
tool.name
for tool in subagent_task_tools
if getattr(tool, "name", None) == SUBAGENT_TASK_TOOL_NAME
)
# 中间件
middlewares = [
@@ -822,6 +857,8 @@ class MoviePilotAgent:
),
# 错误工具调用修复
PatchToolCallsMiddleware(),
# 子代理委派
*subagent_middlewares,
# 用量统计
UsageMiddleware(on_usage=self._record_usage),
]
@@ -839,7 +876,7 @@ class MoviePilotAgent:
middlewares.append(
ToolSelectorMiddleware(
model=non_streaming_model,
selection_tools=tools,
selection_tools=[*tools, *subagent_task_tools],
max_tools=max_tools,
always_include=always_include_tools,
)
@@ -942,6 +979,8 @@ class MoviePilotAgent:
):
if chunk["type"] == "messages":
token, metadata = chunk["data"]
if is_subagent_stream_metadata(metadata):
continue
if not token or not hasattr(token, "tool_call_chunks"):
continue

View File

@@ -293,6 +293,8 @@ class StreamingHandler:
tool_message = (tool_message or "").strip()
tool_message_lower = tool_message.lower()
if tool_name == "task":
return "subagent", tool_kwargs.get("subagent_type")
if tool_name == "read_file":
return "file_read", tool_kwargs.get("file_path")
if tool_name in {"write_file", "edit_file"}:
@@ -408,6 +410,8 @@ class StreamingHandler:
return f"执行了 {count} 次操作"
if category == "interaction":
return f"发起了 {count} 次交互"
if category == "subagent":
return f"已调用 {count} 个子代理"
return f"调用了 {count} 次工具"
def _can_stream(self) -> bool:

View File

@@ -0,0 +1,516 @@
"""MoviePilot 子代理中间件适配。"""
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Optional
from langchain.agents import create_agent
from langchain.agents.middleware.types import (
AgentMiddleware,
ContextT,
ModelRequest,
ModelResponse,
ResponseT,
ToolCallRequest,
)
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.tools.tags import ToolTag
from app.log import logger
SUBAGENT_TASK_TOOL_NAME = "task"
SUBAGENT_STREAM_MARKER_KEY = "ls_agent_type"
SUBAGENT_STREAM_MARKER_VALUE = "subagent"
SUBAGENT_PARENT_PROMPT = """<subagents>
You may use the `task` tool to delegate independent research, retrieval,
diagnosis, or planning work to built-in subagents.
Rules:
- Delegate when a task benefits from focused investigation, such as media identity checks, site/resource search, subscription analysis, download/transfer diagnosis, or read-only system inspection.
- Subagent output is private context for your decision-making. Do not expose a subagent's process or final report verbatim to the user.
- Subagents must not send messages to the user, ask for interaction, or reveal their internal tool activity.
- Give the user only your synthesized final answer and the minimum necessary next step.
- If a task requires configuration changes, deletion, adding downloads, adding subscriptions, or any high-impact action, the main agent must handle it directly under the confirmation policy.
</subagents>"""
SUBAGENT_TASK_DESCRIPTION = (
"Delegate an isolated MoviePilot investigation or planning task to a built-in "
"subagent. The subagent result is private context for the main agent and must "
"not be forwarded verbatim to the user."
)
SUBAGENT_BASE_PROMPT = """You are a silent subagent working for the MoviePilot main agent.
Requirements:
- Handle only the delegated subtask from the main agent. Do not converse with the user.
- Do not send messages, request user interaction, or output progress updates.
- Use tool results only for analysis, and return the final result only to the main agent.
- Unless the task explicitly requires it and your tool set permits it, limit yourself to read-only inspection and diagnosis.
- If user confirmation or a high-impact change is needed, explain why the main agent must confirm it instead of executing it yourself.
- Return a concise structured Chinese result with key evidence, judgment, and recommended next step.
"""
@dataclass(frozen=True)
class _SubAgentProfile:
"""内置子代理定义。"""
name: str
description: str
prompt: str
include_tags: frozenset[str]
exclude_tags: frozenset[str]
class _TaskToolInput(BaseModel):
"""子代理任务工具输入。"""
description: str = Field(..., description="Complete task description for the subagent")
subagent_type: str = Field(
default="general-purpose",
description="Subagent type to invoke, such as general-purpose or media-researcher",
)
def is_subagent_stream_metadata(metadata: Any) -> bool:
"""判断流式 token 元数据是否来自子代理。"""
if not isinstance(metadata, dict):
return False
if metadata.get(SUBAGENT_STREAM_MARKER_KEY) == SUBAGENT_STREAM_MARKER_VALUE:
return True
nested_metadata = metadata.get("metadata")
if isinstance(nested_metadata, dict) and nested_metadata.get(
SUBAGENT_STREAM_MARKER_KEY
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
configurable = metadata.get("configurable")
if isinstance(configurable, dict) and configurable.get(
SUBAGENT_STREAM_MARKER_KEY
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
return bool(metadata.get("lc_agent_name") in builtin_subagent_names())
@lru_cache(maxsize=1)
def builtin_subagent_names() -> frozenset[str]:
"""返回内置子代理名称集合。"""
return frozenset(profile.name for profile in _builtin_subagent_profiles())
@lru_cache(maxsize=1)
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
"""构建 MoviePilot 默认内置子代理定义。"""
default_exclude_tags = frozenset(
{
ToolTag.Write.value,
ToolTag.Message.value,
ToolTag.UserInteraction.value,
}
)
general_tags = frozenset(
{
ToolTag.Media.value,
ToolTag.Resource.value,
ToolTag.Site.value,
ToolTag.Subscription.value,
ToolTag.Download.value,
ToolTag.Library.value,
ToolTag.Transfer.value,
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.Plugin.value,
ToolTag.Workflow.value,
ToolTag.Scheduler.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Web.value,
ToolTag.Command.value,
ToolTag.FilterRule.value,
ToolTag.Persona.value,
ToolTag.SlashCommand.value,
ToolTag.Recommendation.value,
ToolTag.Metadata.value,
}
)
return (
_SubAgentProfile(
name="general-purpose",
description="General read-only investigation subagent for cross-domain MoviePilot analysis and execution recommendations.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in synthesizing media, site, subscription, download, and system status signals."
),
include_tags=general_tags,
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="media-researcher",
description="Media research subagent for title recognition, people, episodes, metadata, and library existence checks.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in media identity resolution, metadata validation, person credits, and library status analysis."
),
include_tags=frozenset(
{
ToolTag.Media.value,
ToolTag.Library.value,
ToolTag.Recommendation.value,
ToolTag.Metadata.value,
ToolTag.Web.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="resource-searcher",
description="Site and resource search subagent for site checks, torrent search, and resource quality analysis.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in site status, site user data, torrent search results, and resource quality judgment."
),
include_tags=frozenset(
{
ToolTag.Resource.value,
ToolTag.Site.value,
ToolTag.Web.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="subscription-analyst",
description="Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions."
),
include_tags=frozenset(
{
ToolTag.Subscription.value,
ToolTag.FilterRule.value,
ToolTag.Settings.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="system-diagnostician",
description="System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics."
),
include_tags=frozenset(
{
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.Plugin.value,
ToolTag.Workflow.value,
ToolTag.Scheduler.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Web.value,
ToolTag.Command.value,
ToolTag.Persona.value,
ToolTag.SlashCommand.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="download-diagnostician",
description="Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state."
),
include_tags=frozenset(
{
ToolTag.Download.value,
ToolTag.Transfer.value,
ToolTag.Library.value,
ToolTag.Directory.value,
ToolTag.File.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
)
def _tool_tag_values(tool: BaseTool) -> set[str]:
"""读取工具实例上的标签集合。"""
tags = getattr(tool, "tags", None) or []
if isinstance(tags, str):
return {tags}
return {str(tag) for tag in tags if tag}
def _select_tools(tools: list[BaseTool], profile: _SubAgentProfile) -> list[BaseTool]:
"""根据工具标签筛选子代理可用工具。"""
selected_tools = []
for tool in tools:
tags = _tool_tag_values(tool)
if ToolTag.Read.value not in tags:
continue
if profile.exclude_tags & tags:
continue
if profile.include_tags & tags:
selected_tools.append(tool)
return selected_tools
def _format_subagent_catalog(profiles: tuple[_SubAgentProfile, ...]) -> str:
"""渲染子代理目录供任务工具描述使用。"""
return "\n".join(
f"- {profile.name}: {profile.description}" for profile in profiles
)
def _extract_text_content(content: Any) -> str:
"""从模型消息内容中提取可读文本。"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts: list[str] = []
for block in content:
if isinstance(block, str):
text_parts.append(block)
continue
if isinstance(block, dict):
if block.get("thought"):
continue
if block.get("type") in {
"thinking",
"reasoning_content",
"reasoning",
"thought",
}:
continue
if isinstance(block.get("text"), str):
text_parts.append(block["text"])
return "".join(text_parts)
return str(content)
def _extract_final_text(result: Any) -> str:
"""从子代理执行结果中提取最后一条 AI 文本。"""
if isinstance(result, dict):
messages = result.get("messages") or []
else:
messages = getattr(result, "messages", []) or []
for message in reversed(messages):
if isinstance(message, AIMessage) and message.content:
text = _extract_text_content(message.content).strip()
if text:
return text
return _extract_text_content(result).strip()
class MoviePilotSubAgentMiddleware(AgentMiddleware):
"""MoviePilot 本地子代理中间件兜底实现。"""
def __init__(
self,
*,
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
system_prompt: str = SUBAGENT_PARENT_PROMPT,
task_description: str = SUBAGENT_TASK_DESCRIPTION,
) -> None:
self.system_prompt = system_prompt
self._model = model
self._profiles = {profile.name: profile for profile in profiles}
self._tools = tools
self._agents = {}
self._default_agent_name = "general-purpose"
self.tools = [
StructuredTool.from_function(
coroutine=self._run_task,
name=SUBAGENT_TASK_TOOL_NAME,
description=(
f"{task_description}\n\nAvailable subagents:\n"
f"{_format_subagent_catalog(profiles)}"
),
args_schema=_TaskToolInput,
)
]
def _get_agent(self, agent_name: str) -> Any:
"""懒加载指定名称的子代理图。"""
profile = self._profiles.get(agent_name) or self._profiles[
self._default_agent_name
]
cached_agent = self._agents.get(profile.name)
if cached_agent:
return cached_agent
subagent_tools = _select_tools(self._tools, profile)
agent = create_agent(
model=self._model,
tools=subagent_tools,
system_prompt=profile.prompt,
name=profile.name,
)
self._agents[profile.name] = agent
return agent
async def _run_task(self, description: str, subagent_type: str) -> str:
"""调用指定子代理并只返回供主代理读取的结果。"""
agent_name = subagent_type or self._default_agent_name
agent = self._get_agent(agent_name)
result = await agent.ainvoke(
{"messages": [HumanMessage(content=description)]},
config={
"configurable": {
"thread_id": f"subagent-{agent_name}-{uuid.uuid4().hex}",
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
},
"metadata": {
"lc_agent_name": agent_name,
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
},
},
)
final_text = _extract_final_text(result)
return final_text or "The subagent did not return a usable result."
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""在主代理模型调用前注入子代理使用说明。"""
new_system_message = append_to_system_message(
request.system_message,
self.system_prompt,
)
return await handler(request.override(system_message=new_system_message))
class SubAgentCallSummaryMiddleware(AgentMiddleware):
"""记录子代理调用次数的中间件。"""
def __init__(self, *, stream_handler: Any = None) -> None:
self.stream_handler = stream_handler
self.tools = []
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在子代理任务工具执行时记录聚合摘要。"""
tool = request.tool
if (
tool
and getattr(tool, "name", None) == SUBAGENT_TASK_TOOL_NAME
and self.stream_handler
and getattr(self.stream_handler, "is_streaming", False)
):
tool_call = request.tool_call or {}
self.stream_handler.record_tool_call(
tool_name=SUBAGENT_TASK_TOOL_NAME,
tool_message="Subagent invoked",
tool_kwargs=tool_call.get("args") or {},
)
return await handler(request)
def _deepagents_spec(
profiles: tuple[_SubAgentProfile, ...], tools: list[BaseTool]
) -> list[dict[str, Any]]:
"""将内置定义转换为 Deep Agents 子代理配置。"""
specs = []
for profile in profiles:
specs.append(
{
"name": profile.name,
"description": profile.description,
"prompt": profile.prompt,
"tools": _select_tools(tools, profile),
}
)
return specs
def _try_create_deepagents_middleware(
*,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
model: BaseChatModel,
) -> Optional[AgentMiddleware]:
"""优先创建 Deep Agents 官方子代理中间件。"""
try:
from deepagents.backends import StateBackend
from deepagents.middleware.subagents import SubAgentMiddleware
return SubAgentMiddleware(
backend=StateBackend(),
subagents=_deepagents_spec(profiles, tools),
default_model=model,
system_prompt=SUBAGENT_PARENT_PROMPT,
task_description=SUBAGENT_TASK_DESCRIPTION,
)
except ImportError:
return None
except Exception as err:
logger.debug(f"Deep Agents 子代理中间件不可用,使用本地实现: {err}")
return None
def create_subagent_middlewares(
*,
model: BaseChatModel,
tools: list[BaseTool],
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
profiles = _builtin_subagent_profiles()
subagent_middleware = _try_create_deepagents_middleware(
profiles=profiles,
tools=tools,
model=model,
)
if subagent_middleware is None:
subagent_middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=profiles,
tools=tools,
)
task_tools = list(getattr(subagent_middleware, "tools", []) or [])
return [
subagent_middleware,
SubAgentCallSummaryMiddleware(stream_handler=stream_handler),
], task_tools
__all__ = [
"SUBAGENT_TASK_TOOL_NAME",
"create_subagent_middlewares",
"is_subagent_stream_metadata",
]

View File

@@ -10,6 +10,7 @@ from langchain_core.tools import BaseTool
from pydantic import PrivateAttr
from app.agent import StreamingHandler
from app.agent.tools.tags import ToolTag
from app.chain import ChainBase
from app.core.config import settings
from app.db.user_oper import UserOper
@@ -132,6 +133,27 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
self._session_id = session_id
self._user_id = user_id
self._require_admin = getattr(self.__class__, "require_admin", False)
self.tags = self._build_tool_tags()
@staticmethod
def _normalize_tag_values(tags: Optional[Any]) -> set[str]:
"""规范化 LangChain 工具标签。"""
if not tags:
return set()
if isinstance(tags, (str, ToolTag)):
tags = [tags]
normalized_tags = set()
for tag in tags:
if isinstance(tag, ToolTag):
normalized_tags.add(tag.value)
elif tag:
normalized_tags.add(str(tag))
return normalized_tags
def _build_tool_tags(self) -> list[str]:
"""规范化工具实现中显式声明的标签。"""
explicit_tags = self._normalize_tag_values(getattr(self, "tags", None))
return sorted(explicit_tags | {ToolTag.AgentTool.value})
def _run(self, *args: Any, **kwargs: Any) -> Any:
raise NotImplementedError("MoviePilotTool 只支持异步调用,请使用 _arun")

View File

@@ -302,7 +302,8 @@ class _TerminalSessionManager:
session.wait_task = asyncio.create_task(self._wait_pipe_process(session))
return session
async def _read_pty(self, session: _TerminalSession) -> None:
@staticmethod
async def _read_pty(session: _TerminalSession) -> None:
"""持续从 PTY 读取增量输出。"""
while session.master_fd is not None:
try:
@@ -319,9 +320,9 @@ class _TerminalSessionManager:
break
session.append_output("pty", data)
@staticmethod
async def _read_pipe(
self,
session: _TerminalSession,
session: _TerminalSession,
stream: asyncio.StreamReader,
stream_name: str,
) -> None:
@@ -361,7 +362,8 @@ class _TerminalSessionManager:
finally:
await self._finish_reader_tasks(session)
async def _finish_reader_tasks(self, session: _TerminalSession) -> None:
@staticmethod
async def _finish_reader_tasks(session: _TerminalSession) -> None:
"""等待输出读取任务退出,超时后取消残留任务。"""
if not session.reader_tasks:
return

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
get_custom_rules,
normalize_custom_rule,
@@ -46,6 +47,11 @@ class AddCustomFilterRuleInput(BaseModel):
class AddCustomFilterRuleTool(MoviePilotTool):
name: str = "add_custom_filter_rule"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Add a custom filter rule to CustomFilterRules. "
"The new rule can then be referenced by rule ID inside filter rule groups."

View File

@@ -7,6 +7,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.chain.download import DownloadChain
@@ -37,6 +38,11 @@ class AddDownloadInput(BaseModel):
class AddDownloadTool(MoviePilotTool):
name: str = "add_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Resource,
]
description: str = "Add torrent download tasks using refs from get_search_results or magnet links."
args_schema: Type[BaseModel] = AddDownloadInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
build_custom_rule_map,
collect_rule_group_usages,
@@ -46,6 +47,11 @@ class AddRuleGroupInput(BaseModel):
class AddRuleGroupTool(MoviePilotTool):
name: str = "add_rule_group"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Add a new filter rule group to UserFilterRuleGroups. "
"Rule groups are matched level by level from left to right and can be linked to search/subscription flows. "

View File

@@ -5,6 +5,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.subscribe import SubscribeChain
from app.db.user_oper import UserOper
from app.log import logger
@@ -72,6 +73,11 @@ class AddSubscribeInput(BaseModel):
class AddSubscribeTool(MoviePilotTool):
name: str = "add_subscribe"
tags: list[str] = [
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Media,
]
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. "

View File

@@ -5,6 +5,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.tags import ToolTag
from app.helper.interaction import (
AgentInteractionOption,
agent_interaction_manager,
@@ -67,6 +68,12 @@ class AskUserChoiceTool(MoviePilotTool):
"""发送按钮选择并让当前 Agent 轮次等待用户回调消息。"""
name: str = "ask_user_choice"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.UserInteraction,
ToolTag.TerminalResponse,
]
sends_message: bool = True
return_direct: bool = True
description: str = (

View File

@@ -9,6 +9,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.log import logger
@@ -89,6 +90,10 @@ class BrowseWebpageInput(BaseModel):
class BrowseWebpageTool(MoviePilotTool):
name: str = "browse_webpage"
tags: list[str] = [
ToolTag.Read,
ToolTag.Web,
]
description: str = (
"Control a real browser (Playwright) to interact with web pages. "
"Supports navigating to URLs, reading page content, taking screenshots, "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
@@ -26,6 +27,11 @@ class DeleteCustomFilterRuleInput(BaseModel):
class DeleteCustomFilterRuleTool(MoviePilotTool):
name: str = "delete_custom_filter_rule"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Delete a custom filter rule from CustomFilterRules. "
"If the rule is still referenced by rule groups, the deletion is blocked to avoid breaking rule_string expressions."

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.log import logger
@@ -29,6 +30,11 @@ class DeleteDownloadInput(BaseModel):
class DeleteDownloadTool(MoviePilotTool):
name: str = "delete_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = "Delete a download task from the downloader by task hash only. Optionally specify the downloader name and whether to delete downloaded files."
args_schema: Type[BaseModel] = DeleteDownloadInput
require_admin: bool = True

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.downloadhistory import DownloadHistory
from app.log import logger
@@ -22,6 +23,11 @@ class DeleteDownloadHistoryInput(BaseModel):
class DeleteDownloadHistoryTool(MoviePilotTool):
name: str = "delete_download_history"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = "Delete a download history record by ID. This only removes the record from the database, does not delete any actual files."
args_schema: Type[BaseModel] = DeleteDownloadHistoryInput
require_admin: bool = True

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
get_rule_groups,
remove_rule_group_references,
@@ -25,6 +26,11 @@ class DeleteRuleGroupInput(BaseModel):
class DeleteRuleGroupTool(MoviePilotTool):
name: str = "delete_rule_group"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Delete a filter rule group from UserFilterRuleGroups. "
"The tool also removes dangling references from global settings and subscriptions."

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.event import eventmanager
from app.db.subscribe_oper import SubscribeOper
from app.helper.server import MoviePilotServerHelper
@@ -25,6 +26,11 @@ class DeleteSubscribeInput(BaseModel):
class DeleteSubscribeTool(MoviePilotTool):
name: str = "delete_subscribe"
tags: list[str] = [
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Admin,
]
description: str = "Delete a media subscription by its ID. This will remove the subscription and stop automatic downloads for that media."
args_schema: Type[BaseModel] = DeleteSubscribeInput
require_admin: bool = True

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
@@ -21,6 +22,11 @@ class DeleteTransferHistoryInput(BaseModel):
class DeleteTransferHistoryTool(MoviePilotTool):
name: str = "delete_transfer_history"
tags: list[str] = [
ToolTag.Write,
ToolTag.Transfer,
ToolTag.Admin,
]
description: str = "Delete a specific transfer history record by its ID. This is useful when you need to remove a failed transfer record before retrying the transfer, as the system skips files that already have transfer history."
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
require_admin: bool = True

View File

@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -20,6 +21,11 @@ class EditFileInput(BaseModel):
class EditFileTool(MoviePilotTool):
name: str = "edit_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.File,
ToolTag.Admin,
]
description: str = "Edit a file by replacing specific old text with new text. Useful for modifying configuration files, code, or scripts."
args_schema: Type[BaseModel] = EditFileInput
require_admin: bool = True

View File

@@ -14,7 +14,8 @@ from typing import Any, Literal, Optional, TextIO, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl.terminal_session import (
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._terminal_session import (
TERMINAL_DEFAULT_READ_BYTES,
TERMINAL_MAX_READ_BYTES,
TERMINAL_WAIT_DEFAULT_MS,
@@ -200,6 +201,11 @@ class ExecuteCommandTool(MoviePilotTool):
"""统一执行和管理 Shell 命令的 Agent 工具。"""
name: str = "execute_command"
tags: list[str] = [
ToolTag.Read,
ToolTag.Command,
ToolTag.Admin,
]
description: str = (
"Start and manage shell commands on the server. By default action=start "
"launches a background session and immediately returns session_id/status/"

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.recommend import RecommendChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -44,6 +45,11 @@ class GetRecommendationsInput(BaseModel):
class GetRecommendationsTool(MoviePilotTool):
name: str = "get_recommendations"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
ToolTag.Recommendation,
]
description: str = "Get trending and popular media recommendations from various sources. Returns curated lists of popular movies, TV shows, and anime based on different criteria like trending, ratings, or calendar schedules. Supports pagination with 20 items per page."
args_schema: Type[BaseModel] = GetRecommendationsInput

View File

@@ -7,6 +7,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.search import SearchChain
from app.log import logger
from ._torrent_search_utils import (
@@ -47,6 +48,10 @@ class GetSearchResultsInput(BaseModel):
class GetSearchResultsTool(MoviePilotTool):
name: str = "get_search_results"
tags: list[str] = [
ToolTag.Read,
ToolTag.Resource,
]
description: str = "Get cached torrent search results from search_torrents with optional filters. Supports pagination with up to 50 results per page."
args_schema: Type[BaseModel] = GetSearchResultsInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
get_plugin_snapshot,
install_plugin_runtime,
@@ -36,6 +37,11 @@ class InstallPluginInput(BaseModel):
class InstallPluginTool(MoviePilotTool):
name: str = "install_plugin"
tags: list[str] = [
ToolTag.Write,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Install a plugin by exact plugin_id from the plugin market or local plugin repositories. "
"Use query_market_plugins first when you need filtering or discovery."

View File

@@ -8,6 +8,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.storage import StorageChain
from app.log import logger
from app.schemas.file import FileItem
@@ -24,6 +25,11 @@ class ListDirectoryInput(BaseModel):
class ListDirectoryTool(MoviePilotTool):
name: str = "list_directory"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.File,
]
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
args_schema: Type[BaseModel] = ListDirectoryInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -18,6 +19,11 @@ class ListSlashCommandsInput(BaseModel):
class ListSlashCommandsTool(MoviePilotTool):
name: str = "list_slash_commands"
tags: list[str] = [
ToolTag.Read,
ToolTag.SlashCommand,
ToolTag.Admin,
]
description: str = (
"List all available slash commands in the system, including system preset commands "
"(e.g. /cookiecloud, /sites, /subscribes, /downloading, /transfer, /restart, etc.) "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.log import logger
@@ -37,6 +38,11 @@ class ModifyDownloadTool(MoviePilotTool):
"""修改下载任务工具"""
name: str = "modify_download"
tags: list[str] = [
ToolTag.Write,
ToolTag.Download,
ToolTag.Admin,
]
description: str = (
"Modify a download task in the downloader by task hash. "
"Supports: 1) Setting tags on a download task, "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
get_builtin_rules,
serialize_builtin_rule,
@@ -27,6 +28,10 @@ class QueryBuiltinFilterRulesInput(BaseModel):
class QueryBuiltinFilterRulesTool(MoviePilotTool):
name: str = "query_builtin_filter_rules"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
]
description: str = (
"Query built-in filter rules defined by the backend filter module. "
"These rule IDs can be used directly inside rule_string expressions for filter rule groups. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
@@ -32,6 +33,10 @@ class QueryCustomFilterRulesInput(BaseModel):
class QueryCustomFilterRulesTool(MoviePilotTool):
name: str = "query_custom_filter_rules"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
]
description: str = (
"Query custom filter rules stored in CustomFilterRules. "
"Custom rules can be referenced from rule_string expressions in filter rule groups. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -20,6 +21,11 @@ class QueryCustomIdentifiersInput(BaseModel):
class QueryCustomIdentifiersTool(MoviePilotTool):
name: str = "query_custom_identifiers"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Query all currently configured custom identifiers (自定义识别词). "
"Returns the list of identifier rules used for preprocessing torrent/file names before media recognition. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.directory import DirectoryHelper
from app.log import logger
@@ -23,6 +24,12 @@ class QueryDirectorySettingsInput(BaseModel):
class QueryDirectorySettingsTool(MoviePilotTool):
name: str = "query_directory_settings"
tags: list[str] = [
ToolTag.Read,
ToolTag.Directory,
ToolTag.Settings,
ToolTag.Admin,
]
description: str = "Query system directory configuration settings (NOT file listings). Returns configured directory paths, storage types, transfer modes, and other directory-related settings. Use 'list_directory' to list actual files and folders in a directory."
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDirectorySettingsInput

View File

@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Type, Union
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.download import DownloadChain
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger
@@ -27,6 +28,10 @@ class QueryDownloadTasksInput(BaseModel):
class QueryDownloadTasksTool(MoviePilotTool):
name: str = "query_download_tasks"
tags: list[str] = [
ToolTag.Read,
ToolTag.Download,
]
description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash, title, or tag. Shows download progress, completion status, tags, and task details from configured downloaders."
args_schema: Type[BaseModel] = QueryDownloadTasksInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -18,6 +19,11 @@ class QueryDownloadersInput(BaseModel):
class QueryDownloadersTool(MoviePilotTool):
name: str = "query_downloaders"
tags: list[str] = [
ToolTag.Read,
ToolTag.Download,
ToolTag.Admin,
]
description: str = "Query downloader configuration and list all available downloaders. Shows downloader status, connection details, and configuration settings."
require_admin: bool = True
args_schema: Type[BaseModel] = QueryDownloadersInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.tmdb import TmdbChain
from app.log import logger
@@ -20,6 +21,10 @@ class QueryEpisodeScheduleInput(BaseModel):
class QueryEpisodeScheduleTool(MoviePilotTool):
name: str = "query_episode_schedule"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Query TV series episode air dates and schedule. Returns non-duplicated schedule fields, including episode list, air-date statistics, and per-episode metadata. Filters out episodes without air dates."
args_schema: Type[BaseModel] = QueryEpisodeScheduleInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
MAX_PLUGIN_CANDIDATE_LIMIT,
@@ -34,6 +35,11 @@ class QueryInstalledPluginsInput(BaseModel):
class QueryInstalledPluginsTool(MoviePilotTool):
name: str = "query_installed_plugins"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query installed plugins in MoviePilot. Returns all installed plugins or filters them by keywords. "
"Use this tool to find the exact plugin_id before uninstall_plugin or other plugin management tools are used."

View File

@@ -8,6 +8,7 @@ from typing import Optional, Type, Any
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.mediaserver import MediaServerChain
from app.helper.mediaserver import MediaServerHelper
from app.log import logger
@@ -84,6 +85,11 @@ class QueryLibraryExistsInput(BaseModel):
class QueryLibraryExistsTool(MoviePilotTool):
name: str = "query_library_exists"
tags: list[str] = [
ToolTag.Read,
ToolTag.Library,
ToolTag.Media,
]
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media."
args_schema: Type[BaseModel] = QueryLibraryExistsInput

View File

@@ -7,6 +7,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.mediaserver import MediaServerChain
from app.helper.service import ServiceConfigHelper
from app.log import logger
@@ -30,6 +31,11 @@ class QueryLibraryLatestInput(BaseModel):
class QueryLibraryLatestTool(MoviePilotTool):
name: str = "query_library_latest"
tags: list[str] = [
ToolTag.Read,
ToolTag.Library,
ToolTag.Media,
]
description: str = "Query the latest media items added to the media server (Plex, Emby, Jellyfin). Returns recently added movies and TV series with their titles, images, links, and other metadata. Supports pagination with 20 items per page."
args_schema: Type[BaseModel] = QueryLibraryLatestInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
DEFAULT_PLUGIN_CANDIDATE_LIMIT,
MAX_PLUGIN_CANDIDATE_LIMIT,
@@ -38,6 +39,11 @@ class QueryMarketPluginsInput(BaseModel):
class QueryMarketPluginsTool(MoviePilotTool):
name: str = "query_market_plugins"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query available plugins from the plugin market and local plugin repositories. "
"Can return the full plugin list or filter by keywords before install_plugin is used."

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas.types import MediaType
@@ -25,6 +26,10 @@ class QueryMediaDetailInput(BaseModel):
class QueryMediaDetailTool(MoviePilotTool):
name: str = "query_media_detail"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
args_schema: Type[BaseModel] = QueryMediaDetailInput

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -26,6 +27,10 @@ class QueryPersonasInput(BaseModel):
class QueryPersonasTool(MoviePilotTool):
name: str = "query_personas"
tags: list[str] = [
ToolTag.Read,
ToolTag.Persona,
]
description: str = (
"List all available personas (人格) and show which one is currently active. "
"Use this before switching persona when the user asks for a different speaking style but does not name "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.plugin import PluginManager
from app.log import logger
@@ -25,6 +26,11 @@ class QueryPluginCapabilitiesInput(BaseModel):
class QueryPluginCapabilitiesTool(MoviePilotTool):
name: str = "query_plugin_capabilities"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query the capabilities of installed plugins, including supported commands and scheduled services. "
"Commands are slash-commands (e.g. /xxx) that can be executed via the run_slash_command tool. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
from app.core.plugin import PluginManager
from app.log import logger
@@ -24,6 +25,11 @@ class QueryPluginConfigInput(BaseModel):
class QueryPluginConfigTool(MoviePilotTool):
name: str = "query_plugin_config"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query the saved configuration of an installed plugin. "
"Returns the current saved config and, when available, the plugin's default config model. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
PLUGIN_DATA_KEY_PREVIEW_LIMIT,
build_preview_payload,
@@ -36,6 +37,11 @@ class QueryPluginDataInput(BaseModel):
class QueryPluginDataTool(MoviePilotTool):
name: str = "query_plugin_data"
tags: list[str] = [
ToolTag.Read,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Query persisted data of an installed plugin. "
"Optionally specify a key to read a single data item; otherwise all plugin data entries are returned. "

View File

@@ -7,6 +7,7 @@ import cn2an
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.context import MediaInfo
from app.helper.server import MoviePilotServerHelper
from app.log import logger
@@ -30,6 +31,11 @@ class QueryPopularSubscribesInput(BaseModel):
class QueryPopularSubscribesTool(MoviePilotTool):
name: str = "query_popular_subscribes"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
ToolTag.Recommendation,
]
description: str = "Query popular subscriptions based on user shared data. Shows media with the most subscribers, supports filtering by genre, rating, minimum subscribers, and pagination."
args_schema: Type[BaseModel] = QueryPopularSubscribesInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_rule_group_usages,
get_rule_groups,
@@ -32,6 +33,10 @@ class QueryRuleGroupsInput(BaseModel):
class QueryRuleGroupsTool(MoviePilotTool):
name: str = "query_rule_groups"
tags: list[str] = [
ToolTag.Read,
ToolTag.FilterRule,
]
description: str = (
"Query filter rule groups (过滤规则组 / 优先级规则组). "
"Each rule group contains a rule_string made of built-in rules and/or custom rules. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -16,6 +17,10 @@ class QuerySchedulersInput(BaseModel):
class QuerySchedulersTool(MoviePilotTool):
name: str = "query_schedulers"
tags: list[str] = [
ToolTag.Read,
ToolTag.Scheduler,
]
description: str = "Query scheduled tasks and list all available scheduler jobs. Shows job status, next run time, and provider information."
args_schema: Type[BaseModel] = QuerySchedulersInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.site import Site
from app.db.models.siteuserdata import SiteUserData
@@ -37,6 +38,11 @@ class QuerySiteUserdataInput(BaseModel):
class QuerySiteUserdataTool(MoviePilotTool):
name: str = "query_site_userdata"
tags: list[str] = [
ToolTag.Read,
ToolTag.Site,
ToolTag.Admin,
]
description: str = "Query user data for a specific site including username, user level, upload/download statistics, seeding information, bonus points, and other account details. Supports querying data for a specific date or latest data."
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySiteUserdataInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.site_oper import SiteOper
from app.log import logger
@@ -26,6 +27,11 @@ class QuerySitesInput(BaseModel):
class QuerySitesTool(MoviePilotTool):
name: str = "query_sites"
tags: list[str] = [
ToolTag.Read,
ToolTag.Site,
ToolTag.Admin,
]
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
require_admin: bool = True
args_schema: Type[BaseModel] = QuerySitesInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.subscribehistory import SubscribeHistory
from app.log import logger
@@ -33,6 +34,10 @@ class QuerySubscribeHistoryInput(BaseModel):
class QuerySubscribeHistoryTool(MoviePilotTool):
name: str = "query_subscribe_history"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
]
description: str = "Query subscription history records. Shows completed subscriptions with their details including name, type, rating, completion date, and other subscription information. Supports filtering by media type and name. Supports pagination with 20 records per page."
args_schema: Type[BaseModel] = QuerySubscribeHistoryInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.server import MoviePilotServerHelper
from app.log import logger
@@ -26,6 +27,10 @@ class QuerySubscribeSharesInput(BaseModel):
class QuerySubscribeSharesTool(MoviePilotTool):
name: str = "query_subscribe_shares"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
]
description: str = "Query shared subscriptions from other users. Shows popular subscriptions shared by the community with filtering and pagination support."
args_schema: Type[BaseModel] = QuerySubscribeSharesInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
from app.schemas.subscribe import Subscribe as SubscribeSchema
@@ -71,6 +72,10 @@ class QuerySubscribesInput(BaseModel):
class QuerySubscribesTool(MoviePilotTool):
name: str = "query_subscribes"
tags: list[str] = [
ToolTag.Read,
ToolTag.Subscription,
]
description: str = "Query subscription status and list user subscriptions. Returns full subscription parameters for each matched subscription. Supports pagination with 100 items per page."
args_schema: Type[BaseModel] = QuerySubscribesInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._system_setting_utils import (
SettingSpec,
list_setting_specs,
@@ -56,6 +57,12 @@ class QuerySystemSettingsInput(BaseModel):
class QuerySystemSettingsTool(MoviePilotTool):
name: str = "query_system_settings"
tags: list[str] = [
ToolTag.Read,
ToolTag.System,
ToolTag.Settings,
ToolTag.Admin,
]
description: str = (
"Query system settings across both the basic Settings module and all SystemConfig-backed categories. "
"Use this tool to inspect downloaders, media servers, notification channels, storages, directories, search-site ranges, "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.models.transferhistory import TransferHistory
from app.log import logger
@@ -24,6 +25,10 @@ class QueryTransferHistoryInput(BaseModel):
class QueryTransferHistoryTool(MoviePilotTool):
name: str = "query_transfer_history"
tags: list[str] = [
ToolTag.Read,
ToolTag.Transfer,
]
description: str = "Query file transfer history records. Shows transfer status, source and destination paths, media information, and transfer details. Supports filtering by title and status."
args_schema: Type[BaseModel] = QueryTransferHistoryInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db import AsyncSessionFactory
from app.db.workflow_oper import WorkflowOper
from app.log import logger
@@ -21,6 +22,10 @@ class QueryWorkflowsInput(BaseModel):
class QueryWorkflowsTool(MoviePilotTool):
name: str = "query_workflows"
tags: list[str] = [
ToolTag.Read,
ToolTag.Workflow,
]
description: str = "Query workflow list and status. Shows workflow name, description, trigger type, state, execution count, and other workflow details. Supports filtering by state, name, and trigger type."
args_schema: Type[BaseModel] = QueryWorkflowsInput

View File

@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
# 最大读取大小 50KB
@@ -22,6 +23,10 @@ class ReadFileInput(BaseModel):
class ReadFileTool(MoviePilotTool):
name: str = "read_file"
tags: list[str] = [
ToolTag.Read,
ToolTag.File,
]
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
args_schema: Type[BaseModel] = ReadFileInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.core.context import Context
from app.core.metainfo import MetaInfo
@@ -23,6 +24,11 @@ class RecognizeMediaInput(BaseModel):
class RecognizeMediaTool(MoviePilotTool):
name: str = "recognize_media"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
ToolTag.Metadata,
]
description: str = "Extract/identify media information from torrent titles or file paths (NOT database search). Supports two modes: 1) Extract from torrent title and optional subtitle, 2) Extract from file path. Returns detailed media information. Use 'search_media' to search TMDB database, or 'scrape_metadata' to generate metadata files for existing files."
args_schema: Type[BaseModel] = RecognizeMediaInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
get_plugin_snapshot,
reload_plugin_runtime,
@@ -26,6 +27,11 @@ class ReloadPluginInput(BaseModel):
class ReloadPluginTool(MoviePilotTool):
name: str = "reload_plugin"
tags: list[str] = [
ToolTag.Write,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Reload an installed plugin so its latest saved configuration takes effect. "
"This also refreshes the plugin's registered commands, scheduled services, and API routes."

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -21,6 +22,11 @@ class RunSchedulerInput(BaseModel):
class RunSchedulerTool(MoviePilotTool):
name: str = "run_scheduler"
tags: list[str] = [
ToolTag.Write,
ToolTag.Scheduler,
ToolTag.Admin,
]
description: str = "Manually trigger a scheduled task to run immediately. This will execute the specified scheduler job by its ID."
args_schema: Type[BaseModel] = RunSchedulerInput
require_admin: bool = True

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.event import eventmanager
from app.log import logger
from app.schemas.types import EventType, MessageChannel
@@ -27,6 +28,11 @@ class RunSlashCommandInput(BaseModel):
class RunSlashCommandTool(MoviePilotTool):
name: str = "run_slash_command"
tags: list[str] = [
ToolTag.Write,
ToolTag.SlashCommand,
ToolTag.Admin,
]
description: str = (
"Execute a slash command (system or plugin) by sending a CommandExcute event. "
"This tool supports ALL registered slash commands, including: "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.workflow import WorkflowChain
from app.db import AsyncSessionFactory
from app.db.workflow_oper import WorkflowOper
@@ -27,6 +28,11 @@ class RunWorkflowInput(BaseModel):
class RunWorkflowTool(MoviePilotTool):
name: str = "run_workflow"
tags: list[str] = [
ToolTag.Write,
ToolTag.Workflow,
ToolTag.Admin,
]
description: str = "Execute a specific workflow manually by workflow ID. Supports running from the beginning or continuing from the last executed action."
args_schema: Type[BaseModel] = RunWorkflowInput
require_admin: bool = True

View File

@@ -7,6 +7,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas import FileItem
@@ -33,6 +34,13 @@ class ScrapeMetadataInput(BaseModel):
class ScrapeMetadataTool(MoviePilotTool):
name: str = "scrape_metadata"
tags: list[str] = [
ToolTag.Write,
ToolTag.Media,
ToolTag.Metadata,
ToolTag.File,
ToolTag.Admin,
]
description: str = "Generate metadata files (NFO files, posters, backgrounds, etc.) for existing media files or directories. Automatically recognizes media information from the file path and creates metadata files. Supports both local and remote storage. Use 'search_media' to search TMDB database, or 'recognize_media' to extract info from torrent titles/file paths without generating files."
require_admin: bool = True
args_schema: Type[BaseModel] = ScrapeMetadataInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -24,6 +25,10 @@ class SearchMediaInput(BaseModel):
class SearchMediaTool(MoviePilotTool):
name: str = "search_media"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Search TMDB database for media resources (movies, TV shows, anime, etc.) by title, year, type, and other criteria. Returns detailed media information from TMDB. Use 'recognize_media' to extract info from torrent titles/file paths, or 'scrape_metadata' to generate metadata files."
args_schema: Type[BaseModel] = SearchMediaInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
@@ -18,6 +19,10 @@ class SearchPersonInput(BaseModel):
class SearchPersonTool(MoviePilotTool):
name: str = "search_person"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Search for person information including actors, directors, etc. Supports searching by name. Returns detailed person information from TMDB, Douban, or Bangumi database."
args_schema: Type[BaseModel] = SearchPersonInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.chain.bangumi import BangumiChain
@@ -22,6 +23,10 @@ class SearchPersonCreditsInput(BaseModel):
class SearchPersonCreditsTool(MoviePilotTool):
name: str = "search_person_credits"
tags: list[str] = [
ToolTag.Read,
ToolTag.Media,
]
description: str = "Search for films and TV shows that a person/actor has appeared in (filmography). Supports searching by person ID from TMDB, Douban, or Bangumi database. Returns a list of media works the person has participated in."
args_schema: Type[BaseModel] = SearchPersonCreditsInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.subscribe import SubscribeChain
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
@@ -23,6 +24,12 @@ class SearchSubscribeInput(BaseModel):
class SearchSubscribeTool(MoviePilotTool):
name: str = "search_subscribe"
tags: list[str] = [
ToolTag.Read,
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Resource,
]
description: str = "Search for missing episodes/resources for a specific subscription. This tool will search torrent sites for the missing episodes of the subscription and automatically download matching resources. Use this when a user wants to search for missing episodes of a specific subscription."
args_schema: Type[BaseModel] = SearchSubscribeInput

View File

@@ -6,6 +6,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.search import SearchChain
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.sites import SitesHelper
@@ -29,6 +30,12 @@ class SearchTorrentsInput(BaseModel):
class SearchTorrentsTool(MoviePilotTool):
name: str = "search_torrents"
tags: list[str] = [
ToolTag.Read,
ToolTag.Resource,
ToolTag.Site,
ToolTag.Media,
]
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")

View File

@@ -9,6 +9,7 @@ from ddgs import DDGS
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.log import logger
@@ -82,6 +83,10 @@ class SearchWebTool(MoviePilotTool):
"""
name: str = "search_web"
tags: list[str] = [
ToolTag.Read,
ToolTag.Web,
]
description: str = (
"Search the web for information when you need current information, facts, "
"or references. Supports DDGS-backed search engine selection, automatic "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.tags import ToolTag
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
@@ -43,6 +44,11 @@ class SendLocalFileInput(BaseModel):
class SendLocalFileTool(MoviePilotTool):
name: str = "send_local_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.File,
]
sends_message: bool = True
description: str = (
"Send a local image or file from the server filesystem to the current user. "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -35,6 +36,11 @@ class SendMessageInput(BaseModel):
class SendMessageTool(MoviePilotTool):
name: str = "send_message"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.Admin,
]
sends_message: bool = True
description: str = "Send notification message to the user through configured notification channels (Telegram, Slack, WeChat, etc.). Supports optional image_url on channels that can send images. Used to inform users about operation results, errors, important updates, or proactively send a relevant image."
args_schema: Type[BaseModel] = SendMessageInput

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.tools.base import MoviePilotTool, ToolChain
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.log import logger
from app.schemas import Notification, NotificationType
@@ -29,6 +30,11 @@ class SendVoiceMessageTool(MoviePilotTool):
"""发送 Agent 语音回复的工具。"""
name: str = "send_voice_message"
tags: list[str] = [
ToolTag.Write,
ToolTag.Message,
ToolTag.TerminalResponse,
]
sends_message: bool = True
return_direct: bool = True
description: str = (

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -26,6 +27,10 @@ class SwitchPersonaInput(BaseModel):
class SwitchPersonaTool(MoviePilotTool):
name: str = "switch_persona"
tags: list[str] = [
ToolTag.Write,
ToolTag.Persona,
]
description: str = (
"Switch the active persona (人格) used by the agent runtime. "
"This change is persistent for future turns. "

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.site import SiteChain
from app.db.site_oper import SiteOper
from app.log import logger
@@ -18,6 +19,10 @@ class TestSiteInput(BaseModel):
class TestSiteTool(MoviePilotTool):
name: str = "test_site"
tags: list[str] = [
ToolTag.Read,
ToolTag.Site,
]
description: str = "Test site connectivity and availability. This will check if a site is accessible and can be logged in. Accepts site ID only."
args_schema: Type[BaseModel] = TestSiteInput

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
from app.schemas import FileItem, MediaType
@@ -54,6 +55,13 @@ class TransferFileInput(BaseModel):
class TransferFileTool(MoviePilotTool):
name: str = "transfer_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.Transfer,
ToolTag.Library,
ToolTag.File,
ToolTag.Admin,
]
description: str = "Transfer/organize a file or directory to the media library. Automatically recognizes media information and organizes files according to configured rules. Supports custom target paths, media identification, and transfer modes."
args_schema: Type[BaseModel] = TransferFileInput
require_admin: bool = True

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import (
list_installed_plugins,
summarize_plugin,
@@ -27,6 +28,11 @@ class UninstallPluginInput(BaseModel):
class UninstallPluginTool(MoviePilotTool):
name: str = "uninstall_plugin"
tags: list[str] = [
ToolTag.Write,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Uninstall an installed plugin by exact plugin_id. "
"Use query_installed_plugins first when you need filtering or discovery."

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
collect_custom_rule_group_refs,
get_custom_rules,
@@ -58,6 +59,11 @@ class UpdateCustomFilterRuleInput(BaseModel):
class UpdateCustomFilterRuleTool(MoviePilotTool):
name: str = "update_custom_filter_rule"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Update an existing custom filter rule. "
"If the rule ID is renamed, all rule groups that reference the old ID are updated automatically."

View File

@@ -6,6 +6,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
@@ -33,6 +34,11 @@ class UpdateCustomIdentifiersInput(BaseModel):
class UpdateCustomIdentifiersTool(MoviePilotTool):
name: str = "update_custom_identifiers"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Update the full list of custom identifiers (自定义识别词) used for preprocessing torrent/file names. "
"This tool REPLACES all existing identifier rules with the provided list. "

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -56,6 +57,11 @@ class UpdatePersonaDefinitionInput(BaseModel):
class UpdatePersonaDefinitionTool(MoviePilotTool):
name: str = "update_persona_definition"
tags: list[str] = [
ToolTag.Write,
ToolTag.Persona,
ToolTag.Admin,
]
description: str = (
"Create or update a runtime persona definition (人格定义) without manually editing PERSONA.md files. "
"Use this when the user explicitly asks to modify how a persona is defined, such as changing tone rules, "

View File

@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot
from app.core.plugin import PluginManager
from app.log import logger
@@ -42,6 +43,11 @@ class UpdatePluginConfigInput(BaseModel):
class UpdatePluginConfigTool(MoviePilotTool):
name: str = "update_plugin_config"
tags: list[str] = [
ToolTag.Write,
ToolTag.Plugin,
ToolTag.Admin,
]
description: str = (
"Update the saved configuration of an installed plugin. "
"By default this performs a partial merge update and does NOT reload the plugin automatically. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._filter_rule_utils import (
build_custom_rule_map,
collect_rule_group_usages,
@@ -50,6 +51,11 @@ class UpdateRuleGroupInput(BaseModel):
class UpdateRuleGroupTool(MoviePilotTool):
name: str = "update_rule_group"
tags: list[str] = [
ToolTag.Write,
ToolTag.FilterRule,
ToolTag.Admin,
]
description: str = (
"Update a filter rule group. "
"If the rule group name changes, its references in global search/subscription settings and per-subscription bindings are updated automatically. "

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.event import eventmanager
from app.db import AsyncSessionFactory
from app.db.models.site import Site
@@ -66,6 +67,11 @@ class UpdateSiteInput(BaseModel):
class UpdateSiteTool(MoviePilotTool):
name: str = "update_site"
tags: list[str] = [
ToolTag.Write,
ToolTag.Site,
ToolTag.Admin,
]
description: str = "Update site configuration including URL, priority, authentication credentials (cookie, UA, API key), proxy settings, rate limits, and other site properties. Supports updating multiple site attributes at once. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
args_schema: Type[BaseModel] = UpdateSiteInput
require_admin: bool = True

View File

@@ -5,6 +5,7 @@ from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.site import SiteChain
from app.db.site_oper import SiteOper
from app.log import logger
@@ -29,6 +30,11 @@ class UpdateSiteCookieInput(BaseModel):
class UpdateSiteCookieTool(MoviePilotTool):
name: str = "update_site_cookie"
tags: list[str] = [
ToolTag.Write,
ToolTag.Site,
ToolTag.Admin,
]
description: str = "Update site Cookie and User-Agent by logging in with username and password. This tool can automatically obtain and update the site's authentication credentials. Supports two-step verification for sites that require it. Accepts site ID only."
args_schema: Type[BaseModel] = UpdateSiteCookieInput
require_admin: bool = True

View File

@@ -6,6 +6,7 @@ from typing import Optional, Type, List
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.event import eventmanager
from app.db import AsyncSessionFactory
from app.db.models.subscribe import Subscribe
@@ -89,6 +90,11 @@ class UpdateSubscribeInput(BaseModel):
class UpdateSubscribeTool(MoviePilotTool):
name: str = "update_subscribe"
tags: list[str] = [
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Admin,
]
description: str = "Update subscription properties including filters, episode counts, state, and other settings. Supports updating quality/resolution filters, episode tracking, subscription state, and download configuration."
args_schema: Type[BaseModel] = UpdateSubscribeInput
require_admin: bool = True

View File

@@ -7,6 +7,7 @@ from typing import Any, Literal, Optional, Type, Union
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.agent.tools.impl._system_setting_utils import (
SettingSpec,
get_default_list_match_field,
@@ -73,6 +74,12 @@ class UpdateSystemSettingsInput(BaseModel):
class UpdateSystemSettingsTool(MoviePilotTool):
name: str = "update_system_settings"
tags: list[str] = [
ToolTag.Write,
ToolTag.System,
ToolTag.Settings,
ToolTag.Admin,
]
description: str = (
"Update system settings across both the basic Settings module and all SystemConfig-backed categories. "
"Supports full replacement, shallow dict merge, and generic list item upsert/remove so the agent can manage downloaders, media servers, notification channels, storages, directories, search-site ranges, subscribe-site ranges, site auth params, AI agent config, and other system settings through one tool."

View File

@@ -7,6 +7,7 @@ from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -19,6 +20,11 @@ class WriteFileInput(BaseModel):
class WriteFileTool(MoviePilotTool):
name: str = "write_file"
tags: list[str] = [
ToolTag.Write,
ToolTag.File,
ToolTag.Admin,
]
description: str = "Write full content to a file. If the file already exists, it will be overwritten. Automatically creates parent directories if they don't exist."
args_schema: Type[BaseModel] = WriteFileInput
require_admin: bool = True

39
app/agent/tools/tags.py Normal file
View File

@@ -0,0 +1,39 @@
"""Agent 工具标签定义。"""
from enum import Enum
class ToolTag(str, Enum):
"""Agent 工具能力标签。"""
AgentTool = "agent_tool"
Read = "read"
Write = "write"
Admin = "admin"
Message = "message"
UserInteraction = "user_interaction"
TerminalResponse = "terminal_response"
Media = "media"
Resource = "resource"
Site = "site"
Subscription = "subscription"
Download = "download"
Library = "library"
Transfer = "transfer"
System = "system"
Settings = "settings"
Plugin = "plugin"
Workflow = "workflow"
Scheduler = "scheduler"
File = "file"
Directory = "directory"
Web = "web"
Command = "command"
FilterRule = "filter_rule"
Persona = "persona"
SlashCommand = "slash_command"
Recommendation = "recommendation"
Metadata = "metadata"
__all__ = ["ToolTag"]

View File

@@ -317,11 +317,13 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
user_id="system",
)
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
@@ -356,11 +358,13 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
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: []
agent._initialize_subagent_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],

View File

@@ -0,0 +1,87 @@
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from langchain_core.language_models.fake_chat_models import FakeListChatModel
import app.agent.middleware.subagents as subagent_module
from app.agent.middleware.subagents import (
MoviePilotSubAgentMiddleware,
SUBAGENT_TASK_TOOL_NAME,
create_subagent_middlewares,
)
from app.agent.tools.tags import ToolTag
class TestAgentSubagents(unittest.TestCase):
def test_create_subagent_middlewares_registers_task_tool(self):
"""子代理中间件应向主 Agent 注册 task 委派工具。"""
model = FakeListChatModel(responses=["ok"])
middlewares, task_tools = create_subagent_middlewares(
model=model,
tools=[],
stream_handler=None,
)
self.assertEqual(len(middlewares), 2)
self.assertEqual([tool.name for tool in task_tools], [SUBAGENT_TASK_TOOL_NAME])
self.assertIn("media-researcher", task_tools[0].description)
self.assertIn("system-diagnostician", task_tools[0].description)
def test_subagent_tools_are_selected_by_tags(self):
"""子代理应根据工具标签筛选工具,而不是依赖工具名名单。"""
model = FakeListChatModel(responses=["ok"])
tools = [
SimpleNamespace(
name="custom_media_lookup",
tags=[ToolTag.Read.value, ToolTag.Media.value],
),
SimpleNamespace(
name="custom_media_writer",
tags=[ToolTag.Read.value, ToolTag.Write.value, ToolTag.Media.value],
),
SimpleNamespace(
name="custom_site_lookup",
tags=[ToolTag.Read.value, ToolTag.Site.value],
),
]
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return kwargs
middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=tools,
)
with patch.object(subagent_module, "create_agent", side_effect=_fake_create_agent):
middleware._get_agent("media-researcher")
self.assertEqual(
[tool.name for tool in captured["tools"]],
["custom_media_lookup"],
)
def test_builtin_tools_declare_tags_in_implementation(self):
"""所有内置工具实现都应显式声明 tags。"""
impl_dir = Path(__file__).resolve().parents[1] / "app" / "agent" / "tools" / "impl"
missing_tools = []
for path in sorted(impl_dir.glob("*.py")):
text = path.read_text()
for block in text.split("\nclass "):
if "(MoviePilotTool)" not in block:
continue
class_name = block.split("(", 1)[0].strip()
if "tags: list[str]" not in block:
missing_tools.append(f"{path.name}:{class_name}")
self.assertEqual([], missing_tools)
if __name__ == "__main__":
unittest.main()

View File

@@ -35,6 +35,9 @@ class TestAgentSummarizationStreaming(unittest.TestCase):
patch.object(
agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"
),
patch.object(
agent_module, "create_subagent_middlewares", return_value=([], [])
),
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
):
@@ -93,6 +96,9 @@ class TestAgentSummarizationStreaming(unittest.TestCase):
patch.object(
agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"
),
patch.object(
agent_module, "create_subagent_middlewares", return_value=([], [])
),
patch.object(
agent_module,
"ToolSelectorMiddleware",
@@ -138,6 +144,9 @@ class TestAgentSummarizationStreaming(unittest.TestCase):
patch.object(
agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"
),
patch.object(
agent_module, "create_subagent_middlewares", return_value=([], [])
),
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
):
@@ -167,6 +176,9 @@ class TestAgentSummarizationStreaming(unittest.TestCase):
patch.object(
agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"
),
patch.object(
agent_module, "create_subagent_middlewares", return_value=([], [])
),
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
patch.object(agent_module.settings, "LLM_MAX_TOOLS", 0),
):

View File

@@ -9,6 +9,7 @@ if not hasattr(langchain_agents, "create_agent"):
langchain_agents.create_agent = lambda *args, **kwargs: None
from app.agent.callback import StreamingHandler
from app.agent.middleware.subagents import is_subagent_stream_metadata
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.api.endpoints.openai import _OpenAIStreamingHandler
@@ -114,6 +115,36 @@ class TestAgentToolStreaming(unittest.TestCase):
"处理中:\n\n(执行了 2 次搜索,读取了 2 个文件)\n\n继续分析",
)
def test_non_verbose_tool_summary_counts_subagents(self):
async def _run():
handler = StreamingHandler()
await handler.start_streaming()
handler.emit("处理中:")
handler.record_tool_call(
tool_name="task",
tool_message="Subagent invoked",
tool_kwargs={"subagent_type": "media-researcher"},
)
handler.record_tool_call(
tool_name="task",
tool_message="Subagent invoked",
tool_kwargs={"subagent_type": "resource-searcher"},
)
return await handler.take()
buffered_message = asyncio.run(_run())
self.assertEqual(buffered_message, "处理中:\n\n(已调用 2 个子代理)\n\n")
def test_subagent_stream_metadata_is_suppressed(self):
self.assertTrue(
is_subagent_stream_metadata(
{"metadata": {"ls_agent_type": "subagent"}}
)
)
self.assertTrue(is_subagent_stream_metadata({"lc_agent_name": "media-researcher"}))
self.assertFalse(is_subagent_stream_metadata({"lc_agent_name": "main"}))
def test_openai_streaming_handler_flushes_pending_summary_to_queue(self):
async def _run():
handler = _OpenAIStreamingHandler()