mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
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:
@@ -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":
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)}"
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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` 指定更小的行号范围继续读取。
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 个文件"
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user