diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 98eb341d..7291b43c 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -18,6 +18,7 @@ from app.agent.callback import StreamingHandler from app.agent.memory import memory_manager from app.agent.middleware.memory import MemoryMiddleware from app.agent.middleware.patch_tool_calls import PatchToolCallsMiddleware +from app.agent.middleware.skills import SkillsMiddleware from app.agent.prompt import prompt_manager from app.agent.tools.factory import MoviePilotToolFactory from app.chain import ChainBase @@ -37,12 +38,12 @@ class MoviePilotAgent: """ def __init__( - self, - session_id: str, - user_id: str = None, - channel: str = None, - source: str = None, - username: str = None, + self, + session_id: str, + user_id: str = None, + channel: str = None, + source: str = None, + username: str = None, ): self.session_id = session_id self.user_id = user_id @@ -90,9 +91,22 @@ class MoviePilotAgent: tools = self._initialize_tools() # 中间件 - middlewares = [] + middlewares = [ + # Skills + SkillsMiddleware( + sources=[str(settings.CONFIG_PATH / "agent" / "skills")], + ), + # 记忆管理 + MemoryMiddleware( + sources=[str(settings.CONFIG_PATH / "agent" / "MEMORY.md")] + ), + # 上下文压缩 + SummarizationMiddleware(model=llm, trigger=("fraction", 0.85)), + # 错误工具调用修复 + PatchToolCallsMiddleware(), + ] - # 工具选择(LLM_MAX_TOOLS > 0 时启用) + # 工具选择 if settings.LLM_MAX_TOOLS > 0: middlewares.append( LLMToolSelectorMiddleware( @@ -100,19 +114,6 @@ class MoviePilotAgent: ) ) - middlewares.extend( - [ - # 记忆管理 - MemoryMiddleware( - sources=[str(settings.CONFIG_PATH / "agent" / "MEMORY.md")] - ), - # 上下文压缩 - SummarizationMiddleware(model=llm, trigger=("fraction", 0.85)), - # 错误工具调用修复 - PatchToolCallsMiddleware(), - ] - ) - return create_agent( model=llm, tools=tools, @@ -175,19 +176,19 @@ class MoviePilotAgent: # 流式运行智能体 async for chunk in agent.astream( - {"messages": messages}, - stream_mode="messages", - config=agent_config, - subgraphs=False, - version="v2", + {"messages": messages}, + stream_mode="messages", + config=agent_config, + subgraphs=False, + version="v2", ): # 处理流式token(过滤工具调用token,只保留模型生成的内容) if chunk["type"] == "messages": token, metadata = chunk["data"] if ( - token - and hasattr(token, "tool_call_chunks") - and not token.tool_call_chunks + token + and hasattr(token, "tool_call_chunks") + and not token.tool_call_chunks ): if token.content: self.stream_handler.emit(token.content) @@ -267,13 +268,13 @@ class AgentManager: self.active_agents.clear() async def process_message( - self, - session_id: str, - user_id: str, - message: str, - channel: str = None, - source: str = None, - username: str = None, + self, + session_id: str, + user_id: str, + message: str, + channel: str = None, + source: str = None, + username: str = None, ) -> str: """ 处理用户消息 diff --git a/app/agent/middleware/memory.py b/app/agent/middleware/memory.py index d661006d..6fdb896e 100644 --- a/app/agent/middleware/memory.py +++ b/app/agent/middleware/memory.py @@ -11,10 +11,10 @@ from langchain.agents.middleware.types import ( PrivateStateAttr, # noqa ResponseT, ) -from langchain_core.messages import SystemMessage, ContentBlock from langchain_core.runnables import RunnableConfig from langgraph.runtime import Runtime +from app.agent.middleware.utils import append_to_system_message from app.log import logger @@ -97,26 +97,6 @@ MEMORY_SYSTEM_PROMPT = """ """ -def append_to_system_message( - system_message: SystemMessage | None, - text: str, -) -> SystemMessage: - """将文本追加到系统消息。 - - 参数: - system_message: 现有的系统消息或 None。 - text: 要添加到系统消息的文本。 - - 返回: - 追加了文本的新 SystemMessage。 - """ - new_content: list[ContentBlock] = list(system_message.content_blocks) if system_message else [] # noqa - if new_content: - text = f"\n\n{text}" - new_content.append({"type": "text", "text": text}) - return SystemMessage(content_blocks=new_content) - - class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # noqa """从 `AGENTS.md` 文件加载代理记忆的中间件。 diff --git a/app/agent/middleware/skills.py b/app/agent/middleware/skills.py new file mode 100644 index 00000000..9faae1fc --- /dev/null +++ b/app/agent/middleware/skills.py @@ -0,0 +1,356 @@ +import re +from collections.abc import Awaitable, Callable +from typing import Annotated, List +from typing import NotRequired, TypedDict + +import yaml # noqa +from aiopathlib import AsyncPath +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ContextT, + ModelRequest, + ModelResponse, + ResponseT, +) +from langchain.agents.middleware.types import PrivateStateAttr # noqa +from langchain_core.runnables import RunnableConfig +from langgraph.runtime import Runtime + +from app.agent.middleware.utils import append_to_system_message +from app.log import logger + +# 安全提示: SKILL.md 文件最大限制为 10MB,防止 DoS 攻击 +MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024 + +# Agent Skills 规范约束 (https://agentskills.io/specification) +MAX_SKILL_NAME_LENGTH = 64 +MAX_SKILL_DESCRIPTION_LENGTH = 1024 +MAX_SKILL_COMPATIBILITY_LENGTH = 500 + + +class SkillMetadata(TypedDict): + """Skill 元数据,符合 Agent Skills 规范。""" + + path: str + """SKILL.md 文件路径。""" + + id: str + """Skill 标识符。 + 约束: 1-64 字符,仅限小写字母/数字/连字符,不能以连字符开头或结尾,无连续连字符,需与父目录名一致。 + """ + + name: str + """Skill 名称。 + 约束: Skill中文描述。 + """ + + description: str + """Skill 功能描述。 + 约束: 1-1024 字符,应说明功能及适用场景。 + """ + + license: str | None + """许可证信息。""" + + compatibility: str | None + """环境依赖或兼容性要求 (最多 500 字符)。""" + + metadata: dict[str, str] + """附加元数据。""" + + allowed_tools: list[str] + """(实验性) Skill 建议使用的工具列表。""" + + +class SkillsState(AgentState): + """skills 中间件状态。""" + + skills_metadata: NotRequired[Annotated[list[SkillMetadata], PrivateStateAttr]] + """已加载的 skill 元数据列表,不传播给父 agent。""" + + +class SkillsStateUpdate(TypedDict): + """skills 中间件状态更新项。""" + + skills_metadata: list[SkillMetadata] + """待合并的 skill 元数据列表。""" + + +def _parse_skill_metadata( # noqa: C901 + content: str, + skill_path: str, + skill_id: str, +) -> SkillMetadata | None: + """从 SKILL.md 内容中解析 YAML 前言并验证元数据。""" + if len(content) > MAX_SKILL_FILE_SIZE: + logger.warning("Skipping %s: content too large (%d bytes)", skill_path, len(content)) + return None + + # 匹配 --- 分隔的 YAML 前言 + frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n" + match = re.match(frontmatter_pattern, content, re.DOTALL) + if not match: + logger.warning("Skipping %s: no valid YAML frontmatter found", skill_path) + return None + frontmatter_str = match.group(1) + + # 解析 YAML + try: + frontmatter_data = yaml.safe_load(frontmatter_str) + except yaml.YAMLError as e: + logger.warning("Invalid YAML in %s: %s", skill_path, e) + return None + + if not isinstance(frontmatter_data, dict): + logger.warning("Skipping %s: frontmatter is not a mapping", skill_path) + return None + + # SKill名称和描述 + name = str(frontmatter_data.get("name", "")).strip() + description = str(frontmatter_data.get("description", "")).strip() + if not name or not description: + logger.warning("Skipping %s: missing required 'name' or 'description'", skill_path) + return None + description_str = description + if len(description_str) > MAX_SKILL_DESCRIPTION_LENGTH: + logger.warning( + "Description exceeds %d characters in %s, truncating", + MAX_SKILL_DESCRIPTION_LENGTH, + skill_path, + ) + description_str = description_str[:MAX_SKILL_DESCRIPTION_LENGTH] + + # 可选的工具列表,支持空格或逗号分隔 + raw_tools = frontmatter_data.get("allowed-tools") + if isinstance(raw_tools, str): + allowed_tools = [ + t.strip(",") # 兼容 Claude Code 风格的逗号分隔 + for t in raw_tools.split() + if t.strip(",") + ] + else: + if raw_tools is not None: + logger.warning( + "Ignoring non-string 'allowed-tools' in %s (got %s)", + skill_path, + type(raw_tools).__name__, + ) + allowed_tools = [] + + # 能力或环境兼容性说明,最多 500 字符 + compatibility_str = str(frontmatter_data.get("compatibility", "")).strip() or None + if compatibility_str and len(compatibility_str) > MAX_SKILL_COMPATIBILITY_LENGTH: + logger.warning( + "Compatibility exceeds %d characters in %s, truncating", + MAX_SKILL_COMPATIBILITY_LENGTH, + skill_path, + ) + compatibility_str = compatibility_str[:MAX_SKILL_COMPATIBILITY_LENGTH] + + return SkillMetadata( + id=skill_id, + name=name, + description=description_str, + path=skill_path, + metadata=_validate_metadata(frontmatter_data.get("metadata", {}), skill_path), + license=str(frontmatter_data.get("license", "")).strip() or None, + compatibility=compatibility_str, + allowed_tools=allowed_tools, + ) + + +def _validate_metadata( + raw: object, + skill_path: str, +) -> dict[str, str]: + """验证并规范化 YAML 前言中的元数据字段,确保为 dict[str, str] 类型。""" + if not isinstance(raw, dict): + if raw: + logger.warning( + "Ignoring non-dict metadata in %s (got %s)", + skill_path, + type(raw).__name__, + ) + return {} + return {str(k): str(v) for k, v in raw.items()} + + +def _format_skill_annotations(skill: SkillMetadata) -> str: + """构建许可证和兼容性说明字符串。""" + parts: list[str] = [] + if skill.get("license"): + parts.append(f"License: {skill['license']}") + if skill.get("compatibility"): + parts.append(f"Compatibility: {skill['compatibility']}") + return ", ".join(parts) + + +async def _alist_skills(source_path: str) -> list[SkillMetadata]: + """异步列出指定路径下的所有技能。 + + 扫描包含 SKILL.md 的目录并解析其元数据。 + """ + skills: list[SkillMetadata] = [] + + # 查找所有技能目录 (包含 SKILL.md 的目录) + skill_dirs: List[AsyncPath] = [] + async for path in AsyncPath(source_path).iterdir(): + if await path.is_dir() and await (path / "SKILL.md").is_file(): + skill_dirs.append(path) + + if not skill_dirs: + return [] + + # 解析已下载的 SKILL.md + for skill_path in skill_dirs: + skill_md_path = skill_path / "SKILL.md" + + skill_content = await skill_path.read_text(encoding="utf-8") + + # 解析元数据 + skill_metadata = _parse_skill_metadata( + content=skill_content, + skill_path=skill_md_path, + skill_id=skill_path.name, + ) + if skill_metadata: + skills.append(skill_metadata) + + return skills + + +SKILLS_SYSTEM_PROMPT = """ + +You have access to a skills library that provides specialized capabilities and domain knowledge. + +{skills_locations} + +**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 + +**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! + +""" + + +class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # noqa + """加载并向系统提示词注入 Agent Skill 的中间件。 + + 按源顺序加载 Skill,后加载的会覆盖重名的。 + """ + + state_schema = SkillsState + + def __init__(self, *, sources: list[str]) -> None: + """初始化 Skill 中间件。""" + self.sources = sources + self.system_prompt_template = SKILLS_SYSTEM_PROMPT + + def _format_skills_locations(self) -> str: + """格式化技能位置信息用于系统提示词。""" + locations = [] + + 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}") + + return "\n".join(locations) + + 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)})" + + lines = [] + for skill in skills: + annotations = _format_skill_annotations(skill) + desc_line = f"- **{skill['id']}**: {skill['name']} - {skill['description']}" + if annotations: + desc_line += f" ({annotations})" + 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, + ) + + new_system_message = append_to_system_message(request.system_message, skills_section) + + return request.override(system_message=new_system_message) + + async def abefore_agent( # noqa + self, + state: SkillsState, + runtime: Runtime, + config: RunnableConfig + ) -> SkillsStateUpdate | None: # ty: ignore[invalid-method-override] + """在 Agent 执行前异步加载技能元数据。 + + 每个会话仅加载一次。若 state 中已有则跳过。 + """ + # 如果 state 中已存在元数据则跳过 + if "skills_metadata" in state: + return None + + all_skills: dict[str, SkillMetadata] = {} + + # 遍历源按顺序加载技能,重名时后者覆盖前者 + for source_path in self.sources: + source_skills = await _alist_skills(source_path) + for skill in source_skills: + all_skills[skill["name"]] = skill + + skills = list(all_skills.values()) + return SkillsStateUpdate(skills_metadata=skills) + + async def awrap_model_call( + self, + request: ModelRequest[ContextT], + handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]], + ) -> ModelResponse[ResponseT]: + """在模型调用时注入技能文档。""" + modified_request = self.modify_request(request) + return await handler(modified_request) + + +__all__ = ["SkillMetadata", "SkillsMiddleware"] diff --git a/app/agent/middleware/utils.py b/app/agent/middleware/utils.py new file mode 100644 index 00000000..c6ebf46f --- /dev/null +++ b/app/agent/middleware/utils.py @@ -0,0 +1,21 @@ +from langchain_core.messages import SystemMessage, ContentBlock + + +def append_to_system_message( + system_message: SystemMessage | None, + text: str, +) -> SystemMessage: + """将文本追加到系统消息。 + + 参数: + system_message: 现有的系统消息或 None。 + text: 要添加到系统消息的文本。 + + 返回: + 追加了文本的新 SystemMessage。 + """ + new_content: list[ContentBlock] = list(system_message.content_blocks) if system_message else [] # noqa + if new_content: + text = f"\n\n{text}" + new_content.append({"type": "text", "text": text}) + return SystemMessage(content_blocks=new_content) diff --git a/app/agent/tools/impl/edit_file.py b/app/agent/tools/impl/edit_file.py index ddf97770..1af9d623 100644 --- a/app/agent/tools/impl/edit_file.py +++ b/app/agent/tools/impl/edit_file.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Optional, Type -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool diff --git a/app/agent/tools/impl/read_file.py b/app/agent/tools/impl/read_file.py index 2e556ddb..07d62927 100644 --- a/app/agent/tools/impl/read_file.py +++ b/app/agent/tools/impl/read_file.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Optional, Type -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool diff --git a/app/agent/tools/impl/write_file.py b/app/agent/tools/impl/write_file.py index 41be6e3b..aab3b2e7 100644 --- a/app/agent/tools/impl/write_file.py +++ b/app/agent/tools/impl/write_file.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Optional, Type -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 831c1ee8..b11c9100 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -3,7 +3,7 @@ import shutil from typing import Annotated, Any, List, Optional import aiofiles -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.concurrency import run_in_threadpool from starlette import status diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 6f8c7c81..d45f0fa1 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -7,7 +7,7 @@ from typing import Optional, Union, Annotated import aiofiles import pillow_avif # noqa 用于自动注册AVIF支持 -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from app.helper.sites import SitesHelper # noqa # noqa from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response from fastapi.responses import StreamingResponse diff --git a/app/api/servcookie.py b/app/api/servcookie.py index f4ee0010..67dba66a 100644 --- a/app/api/servcookie.py +++ b/app/api/servcookie.py @@ -3,7 +3,7 @@ import json from typing import Annotated, Callable, Any, Dict, Optional import aiofiles -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response from fastapi.responses import PlainTextResponse from fastapi.routing import APIRoute diff --git a/app/core/cache.py b/app/core/cache.py index 444488e7..bce1c33e 100644 --- a/app/core/cache.py +++ b/app/core/cache.py @@ -11,7 +11,7 @@ from typing import Any, Dict, Optional, Generator, AsyncGenerator, Tuple, Litera import aiofiles import aioshutil -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from cachetools import LRUCache as MemoryLRUCache from cachetools import TTLCache as MemoryTTLCache from cachetools.keys import hashkey diff --git a/app/helper/plugin.py b/app/helper/plugin.py index fe5fed08..c9149e03 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable import aiofiles import aioshutil import httpx -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet, InvalidSpecifier from packaging.version import Version, InvalidVersion diff --git a/app/utils/security.py b/app/utils/security.py index 025fc5f3..5c49147c 100644 --- a/app/utils/security.py +++ b/app/utils/security.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Optional, Set, Union from urllib.parse import quote, urlparse -from anyio import Path as AsyncPath +from aiopathlib import AsyncPath from app.log import logger