Refactor plugin cache handling and update related docs

This commit is contained in:
jxxghp
2026-06-21 18:29:27 +08:00
parent 43d1abdec8
commit 6647565ec4
14 changed files with 808 additions and 456 deletions
+205 -72
View File
@@ -1,8 +1,9 @@
import json
import re
import shutil
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Annotated, List
from typing import Annotated, List, Optional
from typing import NotRequired, TypedDict
import yaml # noqa
@@ -17,9 +18,12 @@ from langchain.agents.middleware.types import (
)
from langchain.agents.middleware.types import PrivateStateAttr # noqa
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import StructuredTool
from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.tools.tags import ToolTag
from app.log import logger
# 安全提示: SKILL.md 文件最大限制为 10MB,防止 DoS 攻击
@@ -84,6 +88,19 @@ class SkillsStateUpdate(TypedDict):
"""待合并的 skill 元数据列表。"""
class SkillToolInput(BaseModel):
"""Skill 加载工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this skill is needed in the current context",
)
name: str = Field(
...,
description="Skill name or id from the available skills list.",
)
def _parse_skill_metadata( # noqa: C901
content: str,
skill_path: str,
@@ -248,66 +265,56 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
return skills
def _list_skills(source_path: Path) -> list[SkillMetadata]:
"""同步列出指定路径下的所有技能元数据。"""
if not source_path.exists():
return []
skill_dirs = [
path
for path in source_path.iterdir()
if path.is_dir() and (path / "SKILL.md").is_file()
]
if not skill_dirs:
return []
skill_dirs.sort(key=lambda p: p.name.casefold())
skills: list[SkillMetadata] = []
for skill_path in skill_dirs:
skill_md_path = skill_path / "SKILL.md"
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
skill_metadata = _parse_skill_metadata(
content=skill_content,
skill_path=str(skill_md_path),
skill_id=skill_path.name,
)
if skill_metadata:
skills.append(skill_metadata)
return skills
SKILLS_SYSTEM_PROMPT = """
<skills_system>
You have access to a skills library that provides specialized capabilities and domain knowledge.
{skills_locations}
You have access to a skills library for specialized MoviePilot workflows.
**Available Skills:**
{skills_list}
**How to Use Skills (Progressive Disclosure):**
Skills follow a **progressive disclosure** pattern - you see their name and description above, but only read full instructions when needed:
1. **Recognize when a skill applies**: Check if the user's task matches a skill's description
2. **Read the skill's full instructions**: Use the path shown in the skill list above
3. **Follow the skill's instructions**: SKILL.md contains step-by-step workflows, best practices, and examples
4. **Access supporting files**: Skills may include helper scripts, configs, or reference docs - use absolute paths
**Creating New Skills:**
When you identify a repetitive complex workflow or specialized task that would benefit from being a skill, you can create one:
1. **Directory Structure**: Create a new directory in one of the skills locations. The directory name is the `skill-id`.
- Path format: `<skills_location>/<skill-id>/SKILL.md`
- `skill-id` constraints: 1-64 characters, lowercase letters, numbers, and hyphens only.
2. **SKILL.md Format**: Must start with a YAML frontmatter followed by markdown instructions.
```markdown
---
name: Brief tool name (Chinese)
description: Detailed functional description and use cases (1-1024 chars)
allowed-tools: "tool1 tool2" (optional, space-separated list of recommended tools)
compatibility: "Environment requirements" (optional, max 500 chars)
---
# Skill Instructions
Step-by-step workflows, best practices, and examples go here.
```
3. **Supporting Files**: You can add `.py` scripts, `.yaml` configs, or other files within the same skill directory. Reference them using absolute paths in `SKILL.md`.
**When to Use Skills:**
- User's request matches a skill's domain (e.g., "research X" -> web-research skill)
- You need specialized knowledge or structured workflows
- A skill provides proven patterns for complex tasks
**Executing Skill Scripts:**
Skills may contain Python scripts or other executable files. Always use absolute paths from the skill list.
**Example Workflow:**
User: "Can you research the latest developments in quantum computing?"
1. Check available skills -> See "web-research" skill with its path
2. Read the skill using the path shown
3. Follow the skill's research workflow (search -> organize -> synthesize)
4. Use any helper scripts with absolute paths
Remember: Skills make you more capable and consistent. When in doubt, check if a skill exists for the task!
When the user's request matches a skill description, call the `skill` tool with that skill name before taking task actions. Follow the loaded SKILL.md instructions, and load referenced supporting files only when needed. Do not create or rewrite skills unless the user explicitly asks for skill authoring.
</skills_system>
"""
SKILL_TOOL_NAME = "skill"
SKILL_TOOL_DESCRIPTION = """Loads the full instructions for a MoviePilot skill by name or id.
Available skills:
{skills_catalog}
Call this tool when the user's task matches one of the available skills. The tool returns the SKILL.md content and metadata so you can follow the skill's instructions. Do not use this for simple tasks that do not need a skill.
"""
def _extract_version(skill_md: Path) -> int:
"""从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。"""
@@ -402,6 +409,108 @@ def _sync_bundled_skills(bundled_dir: Path, target_dir: Path) -> None:
logger.warning("更新内置技能 '%s' 失败: %s", skill_src.name, e)
class _SkillToolProvider:
"""Skill 工具的目录扫描和文件读取实现。"""
def __init__(self, *, sources: list[str]) -> None:
"""初始化 Skill 工具数据源。"""
self._sources = sources
@staticmethod
def _normalize_name(value: object) -> str:
"""标准化技能名称用于匹配。"""
return str(value or "").strip().casefold()
@classmethod
def _skill_matches(cls, skill: SkillMetadata, query: str) -> bool:
"""判断技能元数据是否匹配用户提供的名称。"""
normalized_query = cls._normalize_name(query)
candidates = [
skill.get("id"),
skill.get("name"),
]
return any(
cls._normalize_name(candidate) == normalized_query
for candidate in candidates
)
async def _find_skill(self, name: str) -> Optional[SkillMetadata]:
"""从中间件配置的 skills 目录中查找指定技能。"""
all_skills: dict[str, SkillMetadata] = {}
for source_path in self._sources:
skill_source_path = AsyncPath(source_path)
if not await skill_source_path.exists():
continue
for skill in await _alist_skills(skill_source_path):
all_skills[skill["name"]] = skill
for skill in all_skills.values():
if self._skill_matches(skill, name):
return skill
return None
@staticmethod
async def _read_skill_content(skill_path: str) -> tuple[str, bool]:
"""读取技能文件内容,并在超出上限时返回截断标记。"""
path = AsyncPath(skill_path)
stat = await path.stat()
truncated = stat.st_size > MAX_SKILL_FILE_SIZE
async with await path.open("rb") as handle:
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
return raw_content.decode("utf-8", errors="replace"), truncated
async def load_skill(self, name: str, explanation: Optional[str] = None) -> str:
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
logger.info(f"加载 Skill: name={name}, explanation={explanation or '-'}")
try:
skill = await self._find_skill(name)
if not skill:
return json.dumps(
{
"success": False,
"message": f"未找到 Skill: {name}",
},
ensure_ascii=False,
)
content, truncated = await self._read_skill_content(skill["path"])
return json.dumps(
{
"success": True,
"skill": {
"id": skill.get("id"),
"name": skill.get("name"),
"description": skill.get("description"),
"path": skill.get("path"),
"allowed_tools": skill.get("allowed_tools", []),
},
"content": content,
"truncated": truncated,
},
ensure_ascii=False,
indent=2,
)
except Exception as err:
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
return json.dumps(
{
"success": False,
"message": f"加载 Skill 时发生错误: {str(err)}",
},
ensure_ascii=False,
)
def _format_skill_tool_catalog(skills: list[SkillMetadata]) -> str:
"""渲染 Skill 工具描述中的可用技能目录。"""
if not skills:
return "(No skills are currently available.)"
return "\n".join(
f"- {skill['id']}: {skill['name']} - {skill['description']}"
for skill in skills
)
class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa
"""加载并向系统提示词注入 Agent Skill 的中间件。
@@ -430,22 +539,55 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
self.sources = sources
self.bundled_skills_dir = bundled_skills_dir
self.system_prompt_template = SKILLS_SYSTEM_PROMPT
self._skill_provider = _SkillToolProvider(sources=sources)
self.tools = [
StructuredTool.from_function(
coroutine=self._skill_provider.load_skill,
name=SKILL_TOOL_NAME,
description=SKILL_TOOL_DESCRIPTION.format(
skills_catalog=_format_skill_tool_catalog(
self._load_skills_metadata()
)
),
args_schema=SkillToolInput,
tags=[ToolTag.Read, ToolTag.Skill],
)
]
def _format_skills_locations(self) -> str:
"""格式化技能位置信息用于系统提示词"""
locations = []
def _sync_bundled_skills(self) -> None:
"""将项目内置 Skill 同步到首个用户技能目录"""
if not self.bundled_skills_dir or not self.sources:
return
bundled = Path(self.bundled_skills_dir)
target = Path(self.sources[0])
try:
_sync_bundled_skills(bundled, target)
except Exception as e:
logger.warning("同步内置技能失败: %s", e)
for i, source_path in enumerate(self.sources):
suffix = " (higher priority)" if i == len(self.sources) - 1 else ""
locations.append(f"**MoviePilot Skills**: `{source_path}`{suffix}")
def _load_skills_metadata(self) -> list[SkillMetadata]:
"""同步加载当前配置目录中的 Skill 元数据。"""
self._sync_bundled_skills()
all_skills: dict[str, SkillMetadata] = {}
for source_path in self.sources:
for skill in _list_skills(Path(source_path)):
all_skills[skill["name"]] = skill
return list(all_skills.values())
return "\n".join(locations)
def _refresh_skill_tool_description(
self, skills: list[SkillMetadata]
) -> None:
"""刷新 skill 工具描述中的可用技能目录。"""
if not self.tools:
return
self.tools[0].description = SKILL_TOOL_DESCRIPTION.format(
skills_catalog=_format_skill_tool_catalog(skills)
)
def _format_skills_list(self, skills: list[SkillMetadata]) -> str:
"""格式化技能元数据列表用于系统提示词。"""
if not skills:
paths = [f"{source_path}" for source_path in self.sources]
return f"(No skills available yet. You can create skills in {' or '.join(paths)})"
return "(No skills available yet.)"
lines = []
for skill in skills:
@@ -456,18 +598,15 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
lines.append(desc_line)
if skill["allowed_tools"]:
lines.append(f" -> Allowed tools: {', '.join(skill['allowed_tools'])}")
lines.append(f" -> Read `{skill['path']}` for full instructions")
return "\n".join(lines)
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将技能文档注入模型请求的系统消息中。"""
skills_metadata = request.state.get("skills_metadata", []) # noqa
skills_locations = self._format_skills_locations()
skills_list = self._format_skills_list(skills_metadata)
skills_section = self.system_prompt_template.format(
skills_locations=skills_locations,
skills_list=skills_list,
)
@@ -489,14 +628,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
if "skills_metadata" in state:
return None
# 自动同步内置技能到首个用户技能目录
if self.bundled_skills_dir and self.sources:
bundled = Path(self.bundled_skills_dir)
target = Path(self.sources[0])
try:
_sync_bundled_skills(bundled, target)
except Exception as e:
logger.warning("同步内置技能失败: %s", e)
self._sync_bundled_skills()
all_skills: dict[str, SkillMetadata] = {}
@@ -511,6 +643,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
all_skills[skill["name"]] = skill
skills = list(all_skills.values())
self._refresh_skill_tool_description(skills)
return SkillsStateUpdate(skills_metadata=skills)
async def awrap_model_call(
@@ -525,4 +658,4 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no
return await handler(modified_request)
__all__ = ["SkillMetadata", "SkillsMiddleware"]
__all__ = ["SKILL_TOOL_NAME", "SkillMetadata", "SkillsMiddleware"]