feat(agent): 新增 apply_patch 多文件补丁编辑工具

参考 Codex apply_patch 设计,支持单次调用对多个文本文件执行新增、
更新和删除:补丁以 *** Begin Patch / *** End Patch 包裹,Update 段
用 @@ 分隔 hunk,上下文行须与当前内容精确一致;先整体校验全部文件
操作,通过后才逐个原子写盘,并以校验期 sha256 防止并发修改。

- 工具描述与系统提示词明确 apply_patch / edit_file / write_file 分工:
  跨多文件或增删文件用 apply_patch,单文件单处替换用 edit_file
- 注册进工具工厂与 ALWAYS_INCLUDE 列表,加入策略 inventory 与
  MCP 隐藏列表,流式回调归类为 file_write
- 同步 create-moviepilot-plugin / publish-moviepilot-plugin /
  create-moviepilot-skill 技能的 allowed-tools 与版本
- 新增 tests/test_agent_apply_patch.py 覆盖解析、多文件应用、
  整体拒绝、权限边界与版本冲突场景
This commit is contained in:
jxxghp
2026-08-17 08:01:59 +08:00
parent 6269a24e9d
commit d7e610e656
13 changed files with 615 additions and 17 deletions
+16
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import re
import threading import threading
from typing import Any, Optional, Tuple from typing import Any, Optional, Tuple
@@ -20,6 +21,19 @@ class _StreamChain(ChainBase):
pass pass
_PATCH_FILE_HEADER_PATTERN = re.compile(
r"\*\*\* (?:Add|Update|Delete) File:\s*(\S+)"
)
def _extract_first_patch_path(patch: Optional[str]) -> Optional[str]:
"""从补丁文本中提取首个文件路径,作为流式消息展示目标。"""
if not patch:
return None
match = _PATCH_FILE_HEADER_PATTERN.search(patch)
return match.group(1) if match else None
class StreamingHandler: class StreamingHandler:
""" """
流式Token缓冲管理器 流式Token缓冲管理器
@@ -337,6 +351,8 @@ class StreamingHandler:
return "file_read", tool_kwargs.get("file_path") return "file_read", tool_kwargs.get("file_path")
if tool_name in {"write_file", "edit_file"}: if tool_name in {"write_file", "edit_file"}:
return "file_write", tool_kwargs.get("file_path") return "file_write", tool_kwargs.get("file_path")
if tool_name == "apply_patch":
return "file_write", _extract_first_patch_path(tool_kwargs.get("patch"))
if tool_name in {"list_directory", "query_directory_settings"}: if tool_name in {"list_directory", "query_directory_settings"}:
return "directory", tool_kwargs.get("path") return "directory", tool_kwargs.get("path")
if tool_name == "browse_webpage": if tool_name == "browse_webpage":
+1
View File
@@ -32,6 +32,7 @@ BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
"add_download_tasks", "add_download_tasks",
"add_rule_group", "add_rule_group",
"add_subscribe", "add_subscribe",
"apply_patch",
"ask_user_choice", "ask_user_choice",
"browse_webpage", "browse_webpage",
"create_agent_task", "create_agent_task",
+1 -1
View File
@@ -67,7 +67,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable. - If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily. - Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended. - For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters. - Read the relevant file before changing it, then pick the editing tool by scope. Use `apply_patch` when one logical change spans multiple files, adds new files, or deletes files: submit a single patch wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`, `*** Update File:`, and `*** Delete File:` sections; every context and removed line must match the current content exactly, and the whole patch is validated before any file is written. Use `edit_file` for a single localized exact replacement within one already-read file; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for one standalone new file; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API. - When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial. - Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`. - Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`.
+3
View File
@@ -66,6 +66,7 @@ from app.agent.tools.impl.list_directory import ListDirectoryTool
from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool
from app.agent.tools.impl.transfer_file import TransferFileTool from app.agent.tools.impl.transfer_file import TransferFileTool
from app.agent.tools.impl.execute_command import ExecuteCommandTool from app.agent.tools.impl.execute_command import ExecuteCommandTool
from app.agent.tools.impl.apply_patch import ApplyPatchTool
from app.agent.tools.impl.edit_file import EditFileTool from app.agent.tools.impl.edit_file import EditFileTool
from app.agent.tools.impl.write_file import WriteFileTool from app.agent.tools.impl.write_file import WriteFileTool
from app.agent.tools.impl.read_file import ReadFileTool from app.agent.tools.impl.read_file import ReadFileTool
@@ -163,6 +164,7 @@ class MoviePilotToolFactory:
UpdatePersonaDefinitionTool, UpdatePersonaDefinitionTool,
ExecuteCommandTool, ExecuteCommandTool,
EditFileTool, EditFileTool,
ApplyPatchTool,
WriteFileTool, WriteFileTool,
ReadFileTool, ReadFileTool,
BrowseWebpageTool, BrowseWebpageTool,
@@ -192,6 +194,7 @@ class MoviePilotToolFactory:
"write_file", "write_file",
"read_file", "read_file",
"edit_file", "edit_file",
"apply_patch",
"execute_command", "execute_command",
"ask_user_choice", "ask_user_choice",
"create_agent_task", "create_agent_task",
+338
View File
@@ -0,0 +1,338 @@
"""多文件补丁应用工具。
参考 Codex apply_patch 设计:一次调用可对多个文本文件执行新增、更新和删除,
先整体校验全部文件操作,通过后才逐个原子写盘。
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Type
from anyio import Path as AsyncPath
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl._file_write_utils import (
FileVersionConflictError,
atomic_write_text,
calculate_file_sha256,
)
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
BEGIN_PATCH_MARKER = "*** Begin Patch"
END_PATCH_MARKER = "*** End Patch"
ADD_FILE_PREFIX = "*** Add File: "
UPDATE_FILE_PREFIX = "*** Update File: "
DELETE_FILE_PREFIX = "*** Delete File: "
HUNK_SEPARATOR_PREFIX = "@@"
class PatchParseError(ValueError):
"""补丁文本无法解析为合法的文件操作序列。"""
class PatchMatchError(ValueError):
"""补丁中的上下文或删除行与文件当前内容不一致。"""
@dataclass
class PatchHunk:
"""更新操作的单个替换片段:按序定位旧行并替换为新行。"""
old_lines: list[str] = field(default_factory=list)
new_lines: list[str] = field(default_factory=list)
@dataclass
class FilePatch:
"""补丁内针对单个文件的一次操作。"""
operation: str # "add" | "update" | "delete"
path: str
added_lines: list[str] = field(default_factory=list)
hunks: list[PatchHunk] = field(default_factory=list)
def parse_patch(patch: str) -> list[FilePatch]:
"""解析以 Begin/End Patch 标记包裹的补丁文本为文件操作序列。"""
lines = patch.strip().splitlines()
if not lines or lines[0].strip() != BEGIN_PATCH_MARKER:
raise PatchParseError(f"补丁必须以 '{BEGIN_PATCH_MARKER}' 开头")
if len(lines) < 2 or lines[-1].strip() != END_PATCH_MARKER:
raise PatchParseError(f"补丁必须以 '{END_PATCH_MARKER}' 结尾")
operations: list[FilePatch] = []
seen_paths: set[str] = set()
current: Optional[FilePatch] = None
current_hunk: Optional[PatchHunk] = None
def close_hunk() -> None:
"""提交当前 hunk,无锚点的纯新增片段无法定位应直接报错。"""
nonlocal current_hunk
if current_hunk is None or current is None:
return
if not current_hunk.old_lines:
raise PatchParseError(
f"文件 {current.path} 的替换片段缺少上下文或删除行,"
"无法定位替换位置,请在片段中包含至少一行不变的上下文"
)
current.hunks.append(current_hunk)
current_hunk = None
def open_section(operation: str, path: str) -> None:
"""开始新的文件段,同一文件在一次补丁中只允许出现一次。"""
nonlocal current
close_hunk()
if not path:
raise PatchParseError(f"'{operation}' 段落缺少文件路径")
if path in seen_paths:
raise PatchParseError(f"文件 {path} 在补丁中出现多次,请合并为一个段落")
seen_paths.add(path)
current = FilePatch(operation=operation, path=path)
operations.append(current)
for raw_line in lines[1:-1]:
if raw_line.startswith(ADD_FILE_PREFIX):
open_section("add", raw_line[len(ADD_FILE_PREFIX) :].strip())
elif raw_line.startswith(UPDATE_FILE_PREFIX):
open_section("update", raw_line[len(UPDATE_FILE_PREFIX) :].strip())
elif raw_line.startswith(DELETE_FILE_PREFIX):
open_section("delete", raw_line[len(DELETE_FILE_PREFIX) :].strip())
elif current is None:
raise PatchParseError(
f"内容行出现在文件段落之前:{raw_line[:80]!r}"
"请先使用 '*** Add File:''*** Update File:''*** Delete File:' 声明文件"
)
elif current.operation == "add":
if not raw_line.startswith("+"):
raise PatchParseError(
f"文件 {current.path} 的新增段落只允许以 '+' 开头的行:{raw_line[:80]!r}"
)
current.added_lines.append(raw_line[1:])
elif current.operation == "update":
if raw_line.startswith(HUNK_SEPARATOR_PREFIX):
close_hunk()
current_hunk = PatchHunk()
elif current_hunk is None:
raise PatchParseError(
f"文件 {current.path} 的更新段落必须先出现 '@@' 片段分隔行"
)
elif raw_line.startswith("+"):
current_hunk.new_lines.append(raw_line[1:])
elif raw_line.startswith("-"):
current_hunk.old_lines.append(raw_line[1:])
else:
# 上下文行遵循 diff 惯例,允许携带一个前导空格标记
context_line = raw_line[1:] if raw_line.startswith(" ") else raw_line
current_hunk.old_lines.append(context_line)
current_hunk.new_lines.append(context_line)
elif raw_line.strip():
raise PatchParseError(
f"文件 {current.path} 的删除段落不允许包含内容行:{raw_line[:80]!r}"
)
close_hunk()
if not operations:
raise PatchParseError("补丁不包含任何文件操作")
return operations
def apply_hunks_to_content(
content: str, hunks: list[PatchHunk], file_label: str
) -> str:
"""按顺序在文件内容中定位并替换每个 hunk,返回更新后的完整内容。"""
ends_with_newline = content.endswith("\n")
if ends_with_newline:
content = content[:-1]
offset = 0
for index, hunk in enumerate(hunks, start=1):
old_block = "\n".join(hunk.old_lines)
new_block = "\n".join(hunk.new_lines)
position = content.find(old_block, offset)
if position < 0:
raise PatchMatchError(
f"文件 {file_label} 的第 {index} 个替换片段与当前内容不匹配,"
"请重新读取文件并确认空格、缩进和换行"
)
content = (
content[:position] + new_block + content[position + len(old_block) :]
)
offset = position + len(new_block)
if ends_with_newline and content and not content.endswith("\n"):
content += "\n"
return content
def _delete_file(path: Path) -> None:
"""删除补丁中标记移除的已有文件。"""
path.unlink()
class ApplyPatchInput(BaseModel):
"""补丁应用工具的输入参数模型。"""
patch: str = Field(
...,
description=(
"A single patch wrapped in '*** Begin Patch' and '*** End Patch'. "
"Sections: '*** Add File: <path>' whose body lines all start with "
"'+'; '*** Update File: <path>' with hunks separated by '@@' lines, "
"where '+' adds, '-' removes, and context lines may carry one "
"leading space and must otherwise match the current file content "
"exactly; '*** Delete File: <path>' with no body. Use one patch for "
"all files touched by one logical change."
),
)
class ApplyPatchTool(MoviePilotTool):
"""按补丁文本对多个本地文本文件执行新增、更新和删除。"""
name: str = "apply_patch"
tags: list[str] = [
ToolTag.Write,
ToolTag.File,
]
description: str = (
"Apply one unified patch to multiple local text files. Prefer it over "
"edit_file when a single logical change spans several files, adds new "
"files, or deletes files: submit one patch wrapped in '*** Begin Patch' "
"/ '*** End Patch' with '*** Add File:', '*** Update File:', and "
"'*** Delete File:' sections. Hunk context and removed lines must match "
"the current file content exactly; the whole patch is validated before "
"any file is written. For a single localized replacement in one "
"already-read file, edit_file is simpler; use write_file to create one "
"standalone new file. Non-admin users can only patch files inside the "
"MoviePilot Agent config directory."
)
args_schema: Type[BaseModel] = ApplyPatchInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据参数生成友好的提示消息"""
patch = kwargs.get("patch", "") or ""
file_count = sum(
patch.count(prefix)
for prefix in (ADD_FILE_PREFIX, UPDATE_FILE_PREFIX, DELETE_FILE_PREFIX)
)
return f"应用补丁: {file_count} 个文件" if file_count else "应用补丁"
async def _plan_operations(
self, operations: list[FilePatch]
) -> tuple[Optional[list], Optional[str]]:
"""校验每个文件操作并计算写入内容,全部通过才返回执行计划。"""
planned = []
for file_patch in operations:
resolved_path, access_error = await self._check_local_file_access(
file_patch.path, operation="打补丁"
)
if access_error:
return None, access_error
path = AsyncPath(resolved_path)
exists = await path.exists()
if file_patch.operation == "add":
if exists:
return None, (
f"错误:文件 {resolved_path} 已存在,不能使用 Add File"
"请改用 '*** Update File:' 或 edit_file 修改。"
)
new_content = (
"\n".join(file_patch.added_lines) + "\n"
if file_patch.added_lines
else ""
)
planned.append((file_patch, resolved_path, Path(resolved_path), new_content, None))
continue
if not exists:
if file_patch.operation == "update":
return None, (
f"错误:文件 {resolved_path} 不存在,不能使用 Update File"
"请改用 '*** Add File:' 创建。"
)
return None, f"错误:文件 {resolved_path} 不存在,无法删除"
if not await path.is_file():
return None, f"错误:{resolved_path} 不是一个文件"
if file_patch.operation == "delete":
planned.append((file_patch, resolved_path, Path(resolved_path), None, None))
continue
if not file_patch.hunks:
return None, (
f"错误:文件 {resolved_path} 的 Update File 段落缺少 '@@' 替换片段"
)
local_path = Path(resolved_path)
content = await path.read_text(encoding="utf-8", errors="strict")
current_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
new_content = apply_hunks_to_content(
content, file_patch.hunks, resolved_path
)
planned.append(
(file_patch, resolved_path, local_path, new_content, current_sha256)
)
return planned, None
async def run(self, patch: str, **kwargs) -> str:
"""解析并整体校验补丁后,逐文件原子应用新增、更新和删除。"""
logger.info("执行工具: apply_patch")
try:
try:
operations = parse_patch(patch)
except PatchParseError as error:
return f"错误:{error}"
planned, plan_error = await self._plan_operations(operations)
if plan_error:
return plan_error
results = []
for file_patch, resolved_path, local_path, new_content, sha256 in planned:
if file_patch.operation == "delete":
await self.run_blocking("default", _delete_file, local_path)
results.append(f"删除 {resolved_path}")
continue
if file_patch.operation == "add" and local_path.exists():
return (
f"错误:文件 {resolved_path} 在应用补丁期间被创建,拒绝覆盖。"
"请确认文件状态后重新应用补丁。"
)
await self.run_blocking(
"default", atomic_write_text, local_path, new_content, sha256
)
new_sha256 = await self.run_blocking(
"default", calculate_file_sha256, local_path
)
verb = "新增" if file_patch.operation == "add" else "更新"
results.append(f"{verb} {resolved_path}sha256={new_sha256}")
logger.info(f"成功应用补丁,共处理 {len(results)} 个文件")
return f"成功应用补丁({len(results)} 个文件):\n" + "\n".join(
f"- {item}" for item in results
)
except PatchMatchError as error:
return f"错误:{error}"
except FileVersionConflictError:
return (
"错误:目标文件在应用补丁期间发生变化,拒绝写入。"
"请重新读取文件并再次应用补丁。"
)
except FileNotFoundError:
return (
"错误:目标文件在应用补丁期间被删除,拒绝继续。"
"请确认文件状态后重新应用补丁。"
)
except PermissionError:
return "错误:没有访问/修改补丁目标文件的权限"
except UnicodeDecodeError:
return "错误:补丁目标文件不是文本文件,无法应用补丁"
except Exception as e:
logger.error(f"应用补丁时发生错误: {str(e)}", exc_info=True)
return f"操作失败: {str(e)}"
+3 -2
View File
@@ -58,8 +58,9 @@ class EditFileTool(MoviePilotTool):
"the match must occur exactly once; use replace_all only for intentional " "the match must occur exactly once; use replace_all only for intentional "
"bulk replacement. old_text cannot be empty, and new files must be " "bulk replacement. old_text cannot be empty, and new files must be "
"created with write_file. Supports an optional SHA-256 conflict check. " "created with write_file. Supports an optional SHA-256 conflict check. "
"Non-admin users can only edit files inside the MoviePilot Agent config " "When one logical change spans multiple files or needs to add or delete "
"directory." "files, use apply_patch instead. Non-admin users can only edit files "
"inside the MoviePilot Agent config directory."
) )
args_schema: Type[BaseModel] = EditFileInput args_schema: Type[BaseModel] = EditFileInput
+1
View File
@@ -25,6 +25,7 @@ MCP_HIDDEN_TOOLS = {
"execute_command", "execute_command",
"search_web", "search_web",
"edit_file", "edit_file",
"apply_patch",
"write_file", "write_file",
"read_file", "read_file",
} }
+1 -1
View File
@@ -291,7 +291,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。 内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。
内置 Agent 的本地文件与命令工具 `read_file``write_file``edit_file` 内置 Agent 的本地文件与命令工具 `read_file``write_file``edit_file`
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的 `apply_patch``execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。 用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用 其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
`start_line``end_line` 指定更小的行号范围继续读取。 `start_line``end_line` 指定更小的行号范围继续读取。
+14 -8
View File
@@ -1,6 +1,6 @@
--- ---
name: create-moviepilot-plugin name: create-moviepilot-plugin
version: 3 version: 4
description: >- description: >-
Use this skill when the user asks to create, modify, debug, validate, or Use this skill when the user asks to create, modify, debug, validate, or
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development, scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
@@ -11,7 +11,7 @@ description: >-
sidebar pages, commands, services, workflow actions, agent tools, and local sidebar pages, commands, services, workflow actions, agent tools, and local
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源, install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面. 插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
allowed-tools: list_directory read_file write_file edit_file execute_command search_web browse_webpage query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command search_web browse_webpage query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
--- ---
# Create MoviePilot Plugin # Create MoviePilot Plugin
@@ -51,12 +51,18 @@ a local plugin source and installed into the running MoviePilot instance.
examples from different major versions. Search the relevant package directory, examples from different major versions. Search the relevant package directory,
`.venv`, or `node_modules` directly with `rg` instead of scanning the entire `.venv`, or `node_modules` directly with `rg` instead of scanning the entire
project without bounds. project without bounds.
- Use `edit_file` for localized changes. Its `old_text` must identify one exact - Pick the editing tool by scope. Use `apply_patch` when one logical change
location by default; add surrounding context instead of enabling spans multiple files, adds new files, or deletes files: submit a single patch
`replace_all` unless every match intentionally changes. wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`,
- Use `write_file` for new files. Existing files require `overwrite=true` for a `*** Update File:`, and `*** Delete File:` sections; every context and
full rewrite; first call `read_file(include_metadata=true)` and pass its removed line must match the current content exactly.
`sha256` as `expected_sha256` when replacing previously read content. - Use `edit_file` for a single localized change in one file. Its `old_text`
must identify one exact location by default; add surrounding context instead
of enabling `replace_all` unless every match intentionally changes.
- Use `write_file` for one standalone new file. Existing files require
`overwrite=true` for a full rewrite; first call
`read_file(include_metadata=true)` and pass its `sha256` as
`expected_sha256` when replacing previously read content.
- Use `execute_command(action="run")` for short validation, Git, and diagnostic - Use `execute_command(action="run")` for short validation, Git, and diagnostic
commands. Use `action="start"` only for interactive or long-running commands, commands. Use `action="start"` only for interactive or long-running commands,
then continue through the returned session ID. then continue through the returned session ID.
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: create-moviepilot-skill name: create-moviepilot-skill
version: 1 version: 2
description: >- description: >-
Use this skill when the user asks to create, scaffold, update, or review a Use this skill when the user asks to create, scaffold, update, or review a
MoviePilot agent skill. This includes adding a new built-in skill under the MoviePilot agent skill. This includes adding a new built-in skill under the
@@ -8,7 +8,7 @@ description: >-
`SKILL.md` frontmatter and workflow instructions, choosing `allowed-tools`, `SKILL.md` frontmatter and workflow instructions, choosing `allowed-tools`,
adding helper scripts when needed, and bumping the built-in skill `version` adding helper scripts when needed, and bumping the built-in skill `version`
so changes can sync into `config/agent/skills`. so changes can sync into `config/agent/skills`.
allowed-tools: list_directory read_file write_file edit_file execute_command allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command
--- ---
# Create MoviePilot Skill # Create MoviePilot Skill
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: publish-moviepilot-plugin name: publish-moviepilot-plugin
version: 1 version: 2
description: >- description: >-
Use this skill when the user asks to publish, upload, sync, pull, push, diff, Use this skill when the user asks to publish, upload, sync, pull, push, diff,
or maintain a MoviePilot local plugin in a GitHub repository. Covers using the or maintain a MoviePilot local plugin in a GitHub repository. Covers using the
@@ -12,7 +12,7 @@ description: >-
repository when no target repository is available. repository when no target repository is available.
Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub, Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub,
从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护. 从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护.
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command query_system_settings update_system_settings
--- ---
# Publish MoviePilot Plugin # Publish MoviePilot Plugin
+227
View File
@@ -0,0 +1,227 @@
"""Agent 多文件补丁应用工具测试。"""
import asyncio
from unittest.mock import patch
from app.agent.tools.impl import apply_patch as apply_patch_module
from app.agent.tools.impl.apply_patch import ApplyPatchTool
def _make_admin_tool(tool_class=ApplyPatchTool):
"""创建带管理员上下文的补丁工具实例。"""
tool = tool_class(session_id="session-1", user_id="admin")
tool.set_agent_context({"is_admin": True})
return tool
def test_apply_patch_supports_add_update_delete_in_one_call(tmp_path):
"""单个补丁应能同时新增、更新和删除多个文件。"""
updated = tmp_path / "plugin.py"
updated.write_text("enabled = False\nversion = 1\n", encoding="utf-8")
deleted = tmp_path / "legacy.py"
deleted.write_text("old code\n", encoding="utf-8")
tool = _make_admin_tool()
patch_text = (
"*** Begin Patch\n"
f"*** Add File: {tmp_path / 'new.py'}\n"
"+print('hello')\n"
f"*** Update File: {updated}\n"
"@@\n"
"-enabled = False\n"
"+enabled = True\n"
" version = 1\n"
f"*** Delete File: {deleted}\n"
"*** End Patch\n"
)
result = asyncio.run(tool.run(patch_text))
assert "成功应用补丁(3 个文件)" in result
assert (tmp_path / "new.py").read_text(encoding="utf-8") == "print('hello')\n"
assert updated.read_text(encoding="utf-8") == "enabled = True\nversion = 1\n"
assert not deleted.exists()
def test_apply_patch_applies_multiple_hunks_in_order(tmp_path):
"""同一文件的多个替换片段应按顺序定位并依次生效。"""
file_path = tmp_path / "plugin.py"
file_path.write_text("alpha\nbeta\ngamma\nbeta\n", encoding="utf-8")
tool = _make_admin_tool()
patch_text = (
"*** Begin Patch\n"
f"*** Update File: {file_path}\n"
"@@\n"
" alpha\n"
"-beta\n"
"+BETA\n"
"@@\n"
" gamma\n"
"-beta\n"
"+BETA2\n"
"*** End Patch\n"
)
result = asyncio.run(tool.run(patch_text))
assert "成功应用补丁(1 个文件)" in result
assert file_path.read_text(encoding="utf-8") == "alpha\nBETA\ngamma\nBETA2\n"
def test_apply_patch_rejects_whole_patch_without_any_write_on_mismatch(tmp_path):
"""上下文不匹配时应整体拒绝,已校验通过的文件也不应被写入。"""
first = tmp_path / "first.py"
first.write_text("keep me\n", encoding="utf-8")
second = tmp_path / "second.py"
second.write_text("actual content\n", encoding="utf-8")
tool = _make_admin_tool()
patch_text = (
"*** Begin Patch\n"
f"*** Update File: {first}\n"
"@@\n"
"-keep me\n"
"+changed\n"
f"*** Update File: {second}\n"
"@@\n"
"-not present in file\n"
"+changed\n"
"*** End Patch\n"
)
result = asyncio.run(tool.run(patch_text))
assert "不匹配" in result
assert first.read_text(encoding="utf-8") == "keep me\n"
assert second.read_text(encoding="utf-8") == "actual content\n"
def test_apply_patch_rejects_invalid_patch_structure(tmp_path):
"""缺失包裹标记、非法段落顺序和无锚点片段都应返回解析错误。"""
file_path = tmp_path / "plugin.py"
file_path.write_text("content\n", encoding="utf-8")
tool = _make_admin_tool()
no_begin = asyncio.run(tool.run(f"*** Update File: {file_path}\n*** End Patch\n"))
no_end = asyncio.run(tool.run("*** Begin Patch\n*** End PatchX\n"))
body_before_section = asyncio.run(
tool.run("*** Begin Patch\n+stray line\n*** End Patch\n")
)
anchorless_hunk = asyncio.run(
tool.run(
"*** Begin Patch\n"
f"*** Update File: {file_path}\n"
"@@\n"
"+only addition\n"
"*** End Patch\n"
)
)
assert "必须以 '*** Begin Patch' 开头" in no_begin
assert "必须以 '*** End Patch' 结尾" in no_end
assert "文件段落之前" in body_before_section
assert "缺少上下文或删除行" in anchorless_hunk
assert file_path.read_text(encoding="utf-8") == "content\n"
def test_apply_patch_rejects_add_existing_and_update_missing_file(tmp_path):
"""Add 已存在文件或 Update 不存在文件应报错并指引正确操作。"""
existing = tmp_path / "existing.py"
existing.write_text("here\n", encoding="utf-8")
missing = tmp_path / "missing.py"
tool = _make_admin_tool()
add_result = asyncio.run(
tool.run(
"*** Begin Patch\n"
f"*** Add File: {existing}\n"
"+line\n"
"*** End Patch\n"
)
)
update_result = asyncio.run(
tool.run(
"*** Begin Patch\n"
f"*** Update File: {missing}\n"
"@@\n"
"-old\n"
"+new\n"
"*** End Patch\n"
)
)
assert "已存在" in add_result
assert "Update File" in add_result
assert "不存在" in update_result
assert "Add File" in update_result
assert existing.read_text(encoding="utf-8") == "here\n"
assert not missing.exists()
def test_apply_patch_enforces_non_admin_path_boundary(tmp_path):
"""普通用户只能对 Agent 配置目录内的文件打补丁。"""
file_path = tmp_path / "plugin.py"
file_path.write_text("before\n", encoding="utf-8")
tool = ApplyPatchTool(session_id="session-1", user_id="user")
tool.set_agent_context({"is_admin": False})
result = asyncio.run(
tool.run(
"*** Begin Patch\n"
f"*** Update File: {file_path}\n"
"@@\n"
"-before\n"
"+after\n"
"*** End Patch\n"
)
)
assert "Agent配置目录" in result
assert file_path.read_text(encoding="utf-8") == "before\n"
def test_apply_patch_detects_version_conflict_during_write(tmp_path):
"""写入阶段检测到文件被并发修改时应拒绝覆盖。"""
file_path = tmp_path / "plugin.py"
file_path.write_text("before\n", encoding="utf-8")
tool = _make_admin_tool()
patch_text = (
"*** Begin Patch\n"
f"*** Update File: {file_path}\n"
"@@\n"
"-before\n"
"+after\n"
"*** End Patch\n"
)
original_write = apply_patch_module.atomic_write_text
def _conflicting_write(path, content, expected_sha256=None):
file_path.write_text("changed elsewhere\n", encoding="utf-8")
original_write(path, content, expected_sha256)
with patch.object(
apply_patch_module, "atomic_write_text", _conflicting_write
):
result = asyncio.run(tool.run(patch_text))
assert "在应用补丁期间发生变化" in result
assert file_path.read_text(encoding="utf-8") == "changed elsewhere\n"
def test_apply_patch_tool_message_counts_patch_files(tmp_path):
"""工具消息应汇总补丁涉及的文件数量。"""
tool = _make_admin_tool()
message = tool.get_tool_message(
patch=(
"*** Begin Patch\n"
"*** Add File: a.py\n"
"+x\n"
"*** Update File: b.py\n"
"@@\n"
"-old\n"
"+new\n"
"*** Delete File: c.py\n"
"*** End Patch\n"
)
)
assert message == "应用补丁: 3 个文件"
+6 -1
View File
@@ -29,7 +29,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"organize-files": "3", "organize-files": "3",
"transfer-failed-retry": "4", "transfer-failed-retry": "4",
"generate-identifiers": "3", "generate-identifiers": "3",
"create-moviepilot-plugin": "3", "create-moviepilot-plugin": "4",
"create-moviepilot-skill": "2",
"publish-moviepilot-plugin": "2",
} }
for skill_name, expected_version in expected_versions.items(): for skill_name, expected_version in expected_versions.items():
@@ -79,6 +81,7 @@ def test_agent_core_prompt_does_not_block_plugin_source_edits() -> None:
assert "file editing tools, or generated patches to change code" not in core_prompt assert "file editing tools, or generated patches to change code" not in core_prompt
assert "write_file" in allowed_tools assert "write_file" in allowed_tools
assert "edit_file" in allowed_tools assert "edit_file" in allowed_tools
assert "apply_patch" in allowed_tools
assert "search_web" in allowed_tools assert "search_web" in allowed_tools
assert "browse_webpage" in allowed_tools assert "browse_webpage" in allowed_tools
@@ -89,6 +92,8 @@ def test_agent_core_prompt_routes_code_tools_safely() -> None:
assert '`execute_command(action="run")` with `rg`' in core_prompt assert '`execute_command(action="run")` with `rg`' in core_prompt
assert "`replace_all=true` only when every match must change" in core_prompt assert "`replace_all=true` only when every match must change" in core_prompt
assert "pick the editing tool by scope" in core_prompt
assert "Use `apply_patch` when one logical change spans multiple files" in core_prompt
assert "Use `action=run` for short bounded commands" in core_prompt assert "Use `action=run` for short bounded commands" in core_prompt
assert "including SSH" in core_prompt assert "including SSH" in core_prompt
assert "Never use shell redirection" in core_prompt assert "Never use shell redirection" in core_prompt