mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 07:25:25 +08:00
优化 Agent 代码编辑工具 (#6218)
This commit is contained in:
@@ -65,7 +65,11 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||
- 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.
|
||||
- Use `execute_command` only for diagnostics, read-only inspection, or commands the user explicitly asked to run. Its default `action=start` starts a managed background session and returns `session_id`, `status`, `last_seq`, and `output_until_seq`; call the same tool again with `action=read`, `action=wait`, `action=write`, or `action=kill` to poll output, wait in short segments, send stdin, or stop the process.
|
||||
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths. Use `list_directory` to inspect one known directory or a supported remote storage backend, and use `read_file` when the exact local file is known.
|
||||
- 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.
|
||||
- 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 `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`.
|
||||
</tool_strategy>
|
||||
|
||||
<media_rules>
|
||||
|
||||
53
app/agent/tools/impl/_file_write_utils.py
Normal file
53
app/agent/tools/impl/_file_write_utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Agent 文件写入工具的共享辅助函数。"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FileVersionConflictError(RuntimeError):
|
||||
"""目标文件在准备写入期间发生变化。"""
|
||||
|
||||
|
||||
def calculate_file_sha256(path: Path) -> str:
|
||||
"""计算文件原始字节的 SHA-256,用于检测陈旧写入。"""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file_handle:
|
||||
for chunk in iter(lambda: file_handle.read(64 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def atomic_write_text(
|
||||
path: Path,
|
||||
content: str,
|
||||
expected_sha256: str | None = None,
|
||||
) -> None:
|
||||
"""校验目标版本后,在同目录写入临时文件并原子替换文本。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temp_name = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file_handle:
|
||||
file_handle.write(content)
|
||||
file_handle.flush()
|
||||
os.fsync(file_handle.fileno())
|
||||
|
||||
if expected_sha256:
|
||||
if (
|
||||
not path.is_file()
|
||||
or calculate_file_sha256(path).casefold()
|
||||
!= expected_sha256.casefold()
|
||||
):
|
||||
raise FileVersionConflictError(str(path))
|
||||
if path.exists():
|
||||
os.chmod(temp_path, path.stat().st_mode)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""文件编辑工具"""
|
||||
"""文件精确编辑工具。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
@@ -7,6 +7,11 @@ 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.log import logger
|
||||
|
||||
@@ -15,18 +20,44 @@ class EditFileInput(BaseModel):
|
||||
"""文件编辑工具的输入参数模型。"""
|
||||
|
||||
file_path: str = Field(..., description="The absolute path of the file to edit")
|
||||
old_text: str = Field(..., description="The exact old text to be replaced")
|
||||
old_text: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"The exact old text to replace. It must be non-empty and uniquely "
|
||||
"identify one location unless replace_all is true."
|
||||
),
|
||||
)
|
||||
new_text: str = Field(..., description="The new text to replace with")
|
||||
replace_all: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Replace every exact match. Keep false for normal code edits so an "
|
||||
"ambiguous match fails instead of changing multiple locations."
|
||||
),
|
||||
)
|
||||
expected_sha256: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[0-9a-fA-F]{64}$",
|
||||
description=(
|
||||
"Optional SHA-256 returned by read_file(include_metadata=true). The "
|
||||
"edit fails if the file changed after it was read."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class EditFileTool(MoviePilotTool):
|
||||
"""使用精确文本匹配安全编辑本地文件。"""
|
||||
|
||||
name: str = "edit_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = (
|
||||
"Edit a local text file by replacing specific old text with new text. "
|
||||
"Edit an existing local text file using an exact text match. By default "
|
||||
"the match must occur exactly once; use replace_all only for intentional "
|
||||
"bulk replacement. old_text cannot be empty, and new files must be "
|
||||
"created with write_file. Supports an optional SHA-256 conflict check. "
|
||||
"Non-admin users can only edit files inside the MoviePilot Agent config "
|
||||
"directory."
|
||||
)
|
||||
@@ -38,7 +69,16 @@ class EditFileTool(MoviePilotTool):
|
||||
file_name = Path(file_path).name if file_path else "未知文件"
|
||||
return f"编辑文件: {file_name}"
|
||||
|
||||
async def run(self, file_path: str, old_text: str, new_text: str, **kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
file_path: str,
|
||||
old_text: str,
|
||||
new_text: str,
|
||||
replace_all: bool = False,
|
||||
expected_sha256: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""校验精确匹配和可选文件版本后,以原子方式写入编辑结果。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
@@ -48,37 +88,74 @@ class EditFileTool(MoviePilotTool):
|
||||
if access_error:
|
||||
return access_error
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
# 校验逻辑:如果要替换特定文本,文件必须存在且包含该文本
|
||||
if not await path.exists():
|
||||
# 如果 old_text 为空,可能用户想直接创建文件,但通常 edit_file 需要匹配旧内容
|
||||
if old_text:
|
||||
return f"错误:文件 {resolved_path} 不存在,无法进行内容替换。"
|
||||
if not old_text:
|
||||
return "错误:old_text 不能为空;创建或完整写入文件请使用 write_file。"
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
path = AsyncPath(resolved_path)
|
||||
if not await path.exists():
|
||||
return f"错误:文件 {resolved_path} 不存在;创建文件请使用 write_file。"
|
||||
|
||||
if not await path.is_file():
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
if await path.exists():
|
||||
content = await path.read_text(encoding="utf-8", errors="replace")
|
||||
if old_text not in content:
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return f"错误:在文件 {resolved_path} 中未找到指定的旧文本。请确保包含所有的空格、缩进 and 换行符。"
|
||||
occurrences = content.count(old_text)
|
||||
new_content = content.replace(old_text, new_text)
|
||||
else:
|
||||
# 文件不存在且 old_text 为空的情形(初始化新文件)
|
||||
new_content = new_text
|
||||
occurrences = 1
|
||||
local_path = Path(resolved_path)
|
||||
current_sha256 = await self.run_blocking(
|
||||
"default", calculate_file_sha256, local_path
|
||||
)
|
||||
if (
|
||||
expected_sha256
|
||||
and current_sha256.casefold() != expected_sha256.casefold()
|
||||
):
|
||||
return (
|
||||
f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。"
|
||||
"请重新读取文件并基于最新内容编辑。"
|
||||
)
|
||||
|
||||
# 自动创建父目录
|
||||
await path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = await path.read_text(encoding="utf-8", errors="strict")
|
||||
occurrences = content.count(old_text)
|
||||
if occurrences == 0:
|
||||
logger.warning(f"编辑文件 {resolved_path} 失败:未找到指定的旧文本块")
|
||||
return (
|
||||
f"错误:在文件 {resolved_path} 中未找到指定的旧文本。"
|
||||
"请重新读取文件并确认空格、缩进和换行。"
|
||||
)
|
||||
if occurrences > 1 and not replace_all:
|
||||
return (
|
||||
f"错误:old_text 在文件 {resolved_path} 中匹配到 {occurrences} 处,"
|
||||
"为避免误改已拒绝编辑。请提供更多上下文使其唯一,或明确设置 "
|
||||
"replace_all=true。"
|
||||
)
|
||||
|
||||
# 写入文件
|
||||
await path.write_text(new_content, encoding="utf-8")
|
||||
replacement_count = occurrences if replace_all else 1
|
||||
new_content = content.replace(
|
||||
old_text,
|
||||
new_text,
|
||||
-1 if replace_all else 1,
|
||||
)
|
||||
await self.run_blocking(
|
||||
"default",
|
||||
atomic_write_text,
|
||||
local_path,
|
||||
new_content,
|
||||
current_sha256,
|
||||
)
|
||||
new_sha256 = await self.run_blocking(
|
||||
"default", calculate_file_sha256, local_path
|
||||
)
|
||||
|
||||
logger.info(f"成功编辑文件 {resolved_path},替换了 {occurrences} 处内容")
|
||||
return f"成功编辑文件 {resolved_path} (替换了 {occurrences} 处匹配内容)"
|
||||
logger.info(
|
||||
f"成功编辑文件 {resolved_path},替换了 {replacement_count} 处内容"
|
||||
)
|
||||
return (
|
||||
f"成功编辑文件 {resolved_path}(替换了 {replacement_count} 处匹配内容,"
|
||||
f"sha256={new_sha256})"
|
||||
)
|
||||
|
||||
except FileVersionConflictError:
|
||||
return (
|
||||
f"错误:文件 {file_path} 在编辑期间发生变化,拒绝覆盖。"
|
||||
"请重新读取文件并再次编辑。"
|
||||
)
|
||||
except PermissionError:
|
||||
return f"错误:没有访问/修改 {file_path} 的权限"
|
||||
except UnicodeDecodeError:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""文件读取工具"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Type
|
||||
|
||||
@@ -16,12 +18,22 @@ MAX_READ_SIZE = 50 * 1024
|
||||
|
||||
class ReadFileInput(BaseModel):
|
||||
"""文件读取工具的输入参数模型。"""
|
||||
|
||||
file_path: str = Field(..., description="The absolute path of the file to read")
|
||||
start_line: Optional[int] = Field(None, description="The starting line number (1-based, inclusive). If not provided, reading starts from the beginning of the file.")
|
||||
end_line: Optional[int] = Field(None, description="The ending line number (1-based, inclusive). If not provided, reading goes until the end of the file.")
|
||||
include_metadata: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Return structured JSON containing content, size, truncation state, "
|
||||
"and SHA-256. Use before a guarded full-file overwrite."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ReadFileTool(MoviePilotTool):
|
||||
"""按行范围读取本地文本文件,并可返回文件版本元数据。"""
|
||||
|
||||
name: str = "read_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
@@ -36,8 +48,15 @@ class ReadFileTool(MoviePilotTool):
|
||||
file_name = Path(file_path).name if file_path else "未知文件"
|
||||
return f"读取文件: {file_name}"
|
||||
|
||||
async def run(self, file_path: str, start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None, **kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
file_path: str,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
include_metadata: bool = False,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""读取指定文本范围,必要时附带完整文件的 SHA-256 元数据。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
|
||||
|
||||
try:
|
||||
@@ -55,7 +74,8 @@ class ReadFileTool(MoviePilotTool):
|
||||
if not await path.is_file():
|
||||
return f"错误:{resolved_path} 不是一个文件"
|
||||
|
||||
content = await path.read_text(encoding="utf-8", errors="replace")
|
||||
raw_content = await path.read_bytes()
|
||||
content = raw_content.decode("utf-8", errors="replace")
|
||||
truncated = False
|
||||
|
||||
if start_line is not None or end_line is not None:
|
||||
@@ -78,6 +98,21 @@ class ReadFileTool(MoviePilotTool):
|
||||
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
|
||||
truncated = True
|
||||
|
||||
if include_metadata:
|
||||
return json.dumps(
|
||||
{
|
||||
"file_path": str(resolved_path),
|
||||
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
||||
"size_bytes": len(raw_content),
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"truncated": truncated,
|
||||
"content": content,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if truncated:
|
||||
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ 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.log import logger
|
||||
|
||||
@@ -16,17 +21,36 @@ class WriteFileInput(BaseModel):
|
||||
|
||||
file_path: str = Field(..., description="The absolute path of the file to write")
|
||||
content: str = Field(..., description="The content to write into the file")
|
||||
overwrite: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Allow replacing an existing file in full. Keep false when creating a "
|
||||
"new file; prefer edit_file for localized changes."
|
||||
),
|
||||
)
|
||||
expected_sha256: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[0-9a-fA-F]{64}$",
|
||||
description=(
|
||||
"Optional SHA-256 returned by read_file(include_metadata=true). When "
|
||||
"overwriting, fail if the existing file no longer has this hash."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WriteFileTool(MoviePilotTool):
|
||||
"""创建本地文本文件,或在显式允许后完整覆盖已有文件。"""
|
||||
|
||||
name: str = "write_file"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = (
|
||||
"Write full content to a local text file. Non-admin users can only write "
|
||||
"inside the MoviePilot Agent config directory."
|
||||
"Create a local text file with complete content. Existing files are "
|
||||
"protected unless overwrite=true; localized changes should use edit_file. "
|
||||
"Supports an optional SHA-256 conflict check and writes atomically. "
|
||||
"Non-admin users can only write inside the MoviePilot Agent config directory."
|
||||
)
|
||||
args_schema: Type[BaseModel] = WriteFileInput
|
||||
|
||||
@@ -36,7 +60,15 @@ class WriteFileTool(MoviePilotTool):
|
||||
file_name = Path(file_path).name if file_path else "未知文件"
|
||||
return f"写入文件: {file_name}"
|
||||
|
||||
async def run(self, file_path: str, content: str, **kwargs) -> str:
|
||||
async def run(
|
||||
self,
|
||||
file_path: str,
|
||||
content: str,
|
||||
overwrite: bool = False,
|
||||
expected_sha256: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""创建或显式覆盖文件,并通过可选哈希阻止陈旧写入。"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}")
|
||||
|
||||
try:
|
||||
@@ -48,18 +80,52 @@ class WriteFileTool(MoviePilotTool):
|
||||
|
||||
path = AsyncPath(resolved_path)
|
||||
|
||||
if await path.exists() and not await path.is_file():
|
||||
exists = await path.exists()
|
||||
if exists and not await path.is_file():
|
||||
return f"错误:{resolved_path} 路径已存在但不是一个文件"
|
||||
if exists and not overwrite:
|
||||
return (
|
||||
f"错误:文件 {resolved_path} 已存在,拒绝完整覆盖。"
|
||||
"局部修改请使用 edit_file;确需重写时设置 overwrite=true。"
|
||||
)
|
||||
if expected_sha256 and not exists:
|
||||
return (
|
||||
f"错误:文件 {resolved_path} 不存在,无法校验 expected_sha256。"
|
||||
"请确认路径和最新文件状态。"
|
||||
)
|
||||
|
||||
# 自动创建父目录
|
||||
await path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path = Path(resolved_path)
|
||||
current_sha256 = None
|
||||
if exists:
|
||||
current_sha256 = await self.run_blocking(
|
||||
"default", calculate_file_sha256, local_path
|
||||
)
|
||||
if expected_sha256:
|
||||
if current_sha256.casefold() != expected_sha256.casefold():
|
||||
return (
|
||||
f"错误:文件 {resolved_path} 已在读取后发生变化,拒绝覆盖。"
|
||||
"请重新读取文件并基于最新内容写入。"
|
||||
)
|
||||
|
||||
# 写入文件
|
||||
await path.write_text(content, encoding="utf-8")
|
||||
await self.run_blocking(
|
||||
"default",
|
||||
atomic_write_text,
|
||||
local_path,
|
||||
content,
|
||||
current_sha256,
|
||||
)
|
||||
new_sha256 = await self.run_blocking(
|
||||
"default", calculate_file_sha256, local_path
|
||||
)
|
||||
|
||||
logger.info(f"成功写入文件 {resolved_path}")
|
||||
return f"成功写入文件 {resolved_path}"
|
||||
return f"成功写入文件 {resolved_path}(sha256={new_sha256})"
|
||||
|
||||
except FileVersionConflictError:
|
||||
return (
|
||||
f"错误:文件 {file_path} 在写入期间发生变化,拒绝覆盖。"
|
||||
"请重新读取文件并再次写入。"
|
||||
)
|
||||
except PermissionError:
|
||||
return f"错误:没有权限写入 {file_path}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -481,6 +481,9 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345
|
||||
- `tool list` 用于动态发现当前服务可调用的工具
|
||||
- `tool show` 会输出参数名、类型和描述
|
||||
- `tool run` 参数格式固定为 `key=value`
|
||||
- `read_file`、`write_file`、`edit_file` 和 `execute_command`
|
||||
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
|
||||
由 Agent 按当前用户权限直接调用这些工具。
|
||||
|
||||
## Scheduler 命令
|
||||
|
||||
|
||||
@@ -239,6 +239,10 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
||||
|
||||
内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。
|
||||
|
||||
内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、
|
||||
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
|
||||
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
|
||||
|
||||
媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
|
||||
|
||||
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: create-moviepilot-plugin
|
||||
version: 2
|
||||
version: 3
|
||||
description: >-
|
||||
Use this skill when the user asks to create, modify, debug, validate, or
|
||||
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
|
||||
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
|
||||
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
|
||||
allowed-tools: list_directory read_file write_file edit_file execute_command 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 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
|
||||
@@ -33,6 +33,33 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
- When working in or from `MoviePilot-Plugins`, read its `README.md`,
|
||||
`docs/Repository_Guide.md`, and `docs/V2_Plugin_Development.md`. For
|
||||
scenario-specific extensions, read the matching `docs/faq/*.md`.
|
||||
|
||||
## Code Tool Workflow
|
||||
|
||||
- Use `execute_command(action="run")` with `rg` and narrow globs or paths to
|
||||
locate plugin classes, extension points, tests, and package entries. Use
|
||||
`list_directory` only when inspecting one known folder or a configured remote
|
||||
storage backend.
|
||||
- Read the relevant implementation and adjacent example before editing.
|
||||
- Before using a Python or Node.js dependency API, determine the exact installed
|
||||
or locked version from requirements, package manifests, lockfiles, local
|
||||
package source, and `.pyi`/`.d.ts` declarations. If those are insufficient,
|
||||
use `search_web` with the official documentation domain and `browse_webpage`
|
||||
to read the matching version. Do not guess API signatures from memory or mix
|
||||
examples from different major versions. Search the relevant package directory,
|
||||
`.venv`, or `node_modules` directly with `rg` instead of scanning the entire
|
||||
project without bounds.
|
||||
- Use `edit_file` for localized changes. 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 new files. 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
|
||||
commands. Use `action="start"` only for interactive or long-running commands,
|
||||
then continue through the returned session ID.
|
||||
- Do not use shell redirection or inline scripts to perform source edits or to
|
||||
bypass a file-tool permission error.
|
||||
- When the plugin uses Vue federation, also read
|
||||
`MoviePilot-Frontend/docs/module-federation-guide.md`,
|
||||
`MoviePilot-Frontend/docs/federation-troubleshooting.md`,
|
||||
|
||||
131
tests/test_agent_file_tools.py
Normal file
131
tests/test_agent_file_tools.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Agent 本地文件搜索与安全编辑工具测试。"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from app.agent.tools.impl.edit_file import EditFileTool
|
||||
from app.agent.tools.impl.read_file import ReadFileTool
|
||||
from app.agent.tools.impl.write_file import WriteFileTool
|
||||
|
||||
|
||||
def _make_admin_tool(tool_class):
|
||||
"""创建带管理员上下文的文件工具实例。"""
|
||||
tool = tool_class(session_id="session-1", user_id="admin")
|
||||
tool.set_agent_context({"is_admin": True})
|
||||
return tool
|
||||
|
||||
|
||||
def test_edit_file_rejects_ambiguous_match_by_default(tmp_path):
|
||||
"""精确编辑默认应拒绝多处匹配,避免静默批量修改代码。"""
|
||||
file_path = tmp_path / "plugin.py"
|
||||
file_path.write_text("enabled = False\nenabled = False\n", encoding="utf-8")
|
||||
tool = _make_admin_tool(EditFileTool)
|
||||
|
||||
result = asyncio.run(
|
||||
tool.run(str(file_path), "enabled = False", "enabled = True")
|
||||
)
|
||||
|
||||
assert "匹配到 2 处" in result
|
||||
assert "replace_all=true" in result
|
||||
assert file_path.read_text(encoding="utf-8") == (
|
||||
"enabled = False\nenabled = False\n"
|
||||
)
|
||||
|
||||
|
||||
def test_edit_file_replace_all_requires_explicit_flag(tmp_path):
|
||||
"""显式开启 replace_all 后才应替换全部精确匹配。"""
|
||||
file_path = tmp_path / "plugin.py"
|
||||
file_path.write_text("old\nold\n", encoding="utf-8")
|
||||
tool = _make_admin_tool(EditFileTool)
|
||||
|
||||
result = asyncio.run(
|
||||
tool.run(str(file_path), "old", "new", replace_all=True)
|
||||
)
|
||||
|
||||
assert "替换了 2 处" in result
|
||||
assert file_path.read_text(encoding="utf-8") == "new\nnew\n"
|
||||
|
||||
|
||||
def test_edit_file_rejects_empty_match_and_missing_file(tmp_path):
|
||||
"""编辑工具不应再通过空匹配隐式创建文件。"""
|
||||
file_path = tmp_path / "missing.py"
|
||||
tool = _make_admin_tool(EditFileTool)
|
||||
|
||||
empty_result = asyncio.run(tool.run(str(file_path), "", "content"))
|
||||
missing_result = asyncio.run(tool.run(str(file_path), "old", "new"))
|
||||
|
||||
assert "old_text 不能为空" in empty_result
|
||||
assert "不存在" in missing_result
|
||||
assert "write_file" in missing_result
|
||||
assert not file_path.exists()
|
||||
|
||||
|
||||
def test_edit_file_rejects_stale_sha256(tmp_path):
|
||||
"""文件在读取后变化时,哈希保护应拒绝基于旧版本编辑。"""
|
||||
file_path = tmp_path / "plugin.py"
|
||||
file_path.write_text("before", encoding="utf-8")
|
||||
old_sha256 = hashlib.sha256(b"before").hexdigest()
|
||||
file_path.write_text("changed elsewhere", encoding="utf-8")
|
||||
tool = _make_admin_tool(EditFileTool)
|
||||
|
||||
result = asyncio.run(
|
||||
tool.run(
|
||||
str(file_path),
|
||||
"changed elsewhere",
|
||||
"agent change",
|
||||
expected_sha256=old_sha256,
|
||||
)
|
||||
)
|
||||
|
||||
assert "已在读取后发生变化" in result
|
||||
assert file_path.read_text(encoding="utf-8") == "changed elsewhere"
|
||||
|
||||
|
||||
def test_write_file_protects_existing_file_and_supports_guarded_overwrite(tmp_path):
|
||||
"""完整写入应默认保护已有文件,并允许带版本校验的显式覆盖。"""
|
||||
file_path = tmp_path / "plugin.py"
|
||||
file_path.write_text("before", encoding="utf-8")
|
||||
expected_sha256 = hashlib.sha256(b"before").hexdigest()
|
||||
tool = _make_admin_tool(WriteFileTool)
|
||||
|
||||
refused_result = asyncio.run(tool.run(str(file_path), "unexpected"))
|
||||
written_result = asyncio.run(
|
||||
tool.run(
|
||||
str(file_path),
|
||||
"after",
|
||||
overwrite=True,
|
||||
expected_sha256=expected_sha256,
|
||||
)
|
||||
)
|
||||
stale_result = asyncio.run(
|
||||
tool.run(
|
||||
str(file_path),
|
||||
"stale write",
|
||||
overwrite=True,
|
||||
expected_sha256=expected_sha256,
|
||||
)
|
||||
)
|
||||
|
||||
assert "拒绝完整覆盖" in refused_result
|
||||
assert "成功写入文件" in written_result
|
||||
assert "sha256=" in written_result
|
||||
assert "已在读取后发生变化" in stale_result
|
||||
assert file_path.read_text(encoding="utf-8") == "after"
|
||||
|
||||
|
||||
def test_read_file_can_return_sha256_metadata(tmp_path):
|
||||
"""读取工具应能返回供后续冲突检查使用的文件哈希。"""
|
||||
file_path = tmp_path / "plugin.py"
|
||||
file_path.write_text("插件内容", encoding="utf-8")
|
||||
tool = _make_admin_tool(ReadFileTool)
|
||||
|
||||
result = asyncio.run(tool.run(str(file_path), include_metadata=True))
|
||||
payload = json.loads(result)
|
||||
|
||||
assert payload["content"] == "插件内容"
|
||||
assert payload["size_bytes"] == len("插件内容".encode("utf-8"))
|
||||
assert payload["sha256"] == hashlib.sha256(
|
||||
"插件内容".encode("utf-8")
|
||||
).hexdigest()
|
||||
assert payload["truncated"] is False
|
||||
@@ -49,7 +49,12 @@ def test_non_admin_manager_exposes_restricted_file_tools():
|
||||
manager = MoviePilotToolsManager(is_admin=False)
|
||||
|
||||
tool_names = {tool.name for tool in manager.list_tools()}
|
||||
assert {"read_file", "write_file", "edit_file", "list_directory"} <= tool_names
|
||||
assert {
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"list_directory",
|
||||
} <= tool_names
|
||||
|
||||
|
||||
def test_non_admin_manager_hides_admin_only_send_local_file_tool():
|
||||
|
||||
@@ -27,6 +27,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"moviepilot-cli": "6",
|
||||
"moviepilot-update": "3",
|
||||
"transfer-failed-retry": "2",
|
||||
"create-moviepilot-plugin": "3",
|
||||
}
|
||||
|
||||
for skill_name, expected_version in expected_versions.items():
|
||||
@@ -76,3 +77,18 @@ 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 "write_file" in allowed_tools
|
||||
assert "edit_file" in allowed_tools
|
||||
assert "search_web" in allowed_tools
|
||||
assert "browse_webpage" in allowed_tools
|
||||
|
||||
|
||||
def test_agent_core_prompt_routes_code_tools_safely() -> None:
|
||||
"""核心提示词应区分代码搜索、精确编辑和交互式命令场景。"""
|
||||
core_prompt = CORE_PROMPT_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert '`execute_command(action="run")` with `rg`' in core_prompt
|
||||
assert "`replace_all=true` only when every match must change" in core_prompt
|
||||
assert "Use `action=run` for short bounded commands" in core_prompt
|
||||
assert "including SSH" in core_prompt
|
||||
assert "Never use shell redirection" in core_prompt
|
||||
assert "matching version of the official documentation" in core_prompt
|
||||
assert "Do not guess signatures from memory" in core_prompt
|
||||
|
||||
Reference in New Issue
Block a user