From d1cfd0615158e129f750739aa58ee1897591204c Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Mon, 31 Aug 2026 16:34:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=AF=B9=E9=BD=90=20Agent=20Skills?= =?UTF-8?q?=20=E6=A0=87=E5=87=86=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:现有 Skills Runtime 要求 Fusion 自定义版本目录、顶层 version 和列表式 allowed-tools,无法直接承接 Anthropic、OpenAI、Vercel 等社区 SKILL.md。 改动:新增安全 YAML 标准解析层;兼容标准目录、metadata.version、可选 allowed-tools 和旧版字段;无版本文档按内容摘要冻结;现有 verified-research 迁移到标准目录,同时保持 Run、Trajectory 与工具权限收口契约。 验证:完整测试 3568 项、1917 个子测试通过,2 项跳过;架构检查、ruff check、改动文件格式检查和真实社区样本解析通过。 Co-Authored-By: Codex --- app/ai/skills/__init__.py | 5 +- app/ai/skills/document.py | 205 ++++++++++++++++++ app/ai/skills/registry.py | 152 ++++--------- .../verified-research/{1.0.0 => }/SKILL.md | 7 +- app/schemas/trajectory.py | 2 +- requirements.txt | 1 + test/ai/skills/test_document.py | 169 +++++++++++++++ test/ai/skills/test_registry.py | 85 ++++++-- test/services/agent/test_events.py | 16 ++ 9 files changed, 512 insertions(+), 130 deletions(-) create mode 100644 app/ai/skills/document.py rename app/ai/skills/verified-research/{1.0.0 => }/SKILL.md (94%) create mode 100644 test/ai/skills/test_document.py diff --git a/app/ai/skills/__init__.py b/app/ai/skills/__init__.py index da4c80d0..299e34d6 100644 --- a/app/ai/skills/__init__.py +++ b/app/ai/skills/__init__.py @@ -1,5 +1,6 @@ -"""Fusion 代码托管 Skills。""" +"""Agent Skills 标准文档与 Fusion Run 级加载适配。""" +from app.ai.skills.document import SkillDocument, parse_skill_document from app.ai.skills.registry import ( LoadedSkillSnapshot, RunSkillResolution, @@ -13,5 +14,7 @@ "RunSkillResolution", "SkillLoadResult", "SkillMetadata", + "SkillDocument", "load_skills_for_package", + "parse_skill_document", ] diff --git a/app/ai/skills/document.py b/app/ai/skills/document.py new file mode 100644 index 00000000..0c5402d7 --- /dev/null +++ b/app/ai/skills/document.py @@ -0,0 +1,205 @@ +"""解析 Agent Skills 标准的 ``SKILL.md`` 文档。""" + +from __future__ import annotations + +import hashlib +import re +import shlex +from dataclasses import dataclass + +import yaml +from yaml.events import AliasEvent +from yaml.nodes import MappingNode +from yaml.resolver import BaseResolver + +MAX_SKILL_FILE_BYTES = 32 * 1024 + +_SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_RUNTIME_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$") +_STANDARD_FIELDS = frozenset( + { + "name", + "description", + "license", + "compatibility", + "metadata", + "allowed-tools", + } +) +_LEGACY_FIELDS = frozenset({"version"}) + + +class _StrictSafeLoader(yaml.SafeLoader): + """禁用别名并拒绝重复键的安全 YAML loader。""" + + def compose_node(self, parent, index): + if self.check_event(AliasEvent): + raise ValueError("Skill frontmatter 不允许 YAML 别名") + return super().compose_node(parent, index) + + +def _construct_unique_mapping(loader: _StrictSafeLoader, node: MappingNode, deep: bool = False) -> dict: + if not isinstance(node, MappingNode): + raise ValueError("Skill frontmatter 映射结构非法") + mapping = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in mapping + except TypeError as exc: + raise ValueError("Skill frontmatter 字段名非法") from exc + if duplicate: + raise ValueError("Skill frontmatter 包含重复字段") + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +_StrictSafeLoader.add_constructor(BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping) + + +@dataclass(frozen=True, slots=True) +class SkillDocument: + """与具体 Agent Runtime 解耦的 SKILL.md 解析结果。""" + + name: str + description: str + body: str + license: str | None + compatibility: str | None + metadata: tuple[tuple[str, str], ...] + declared_version: str | None + declared_allowed_tools: tuple[str, ...] + extension_field_names: tuple[str, ...] + document_sha256: str + + @property + def resolved_version(self) -> str: + """返回 Run 账本可冻结的版本;无声明版本时使用整份文档摘要。""" + + if self.declared_version and _RUNTIME_VERSION_RE.fullmatch(self.declared_version): + return self.declared_version + return f"sha256-{self.document_sha256[:16]}" + + +def parse_skill_document(document: str, *, expected_skill_id: str | None = None) -> SkillDocument: + """按 Agent Skills 标准解析文档,同时兼容既有 Fusion/DeerFlow 字段形态。""" + + if not isinstance(document, str): + raise ValueError("Skill 文档必须是字符串") + normalized = document.replace("\r\n", "\n").replace("\r", "\n") + if len(normalized.encode("utf-8")) > MAX_SKILL_FILE_BYTES: + raise ValueError("Skill 文件超过大小上限") + + frontmatter_text, body = _split_document(normalized) + try: + frontmatter = yaml.load(frontmatter_text, Loader=_StrictSafeLoader) + except (ValueError, yaml.YAMLError) as exc: + raise ValueError("Skill frontmatter YAML 非法") from exc + if not isinstance(frontmatter, dict) or any(not isinstance(key, str) for key in frontmatter): + raise ValueError("Skill frontmatter 必须是字符串键映射") + + name = _required_string(frontmatter, "name") + description = _required_string(frontmatter, "description") + if len(name) > 64 or _SKILL_ID_RE.fullmatch(name) is None: + raise ValueError("Skill name 不符合 Agent Skills 标准") + if expected_skill_id is not None and name != expected_skill_id: + raise ValueError("Skill name 与目录身份不一致") + if len(description) > 1024: + raise ValueError("Skill description 超过大小上限") + + license_name = _optional_string(frontmatter, "license") + compatibility = _optional_string(frontmatter, "compatibility") + if compatibility is not None and len(compatibility) > 500: + raise ValueError("Skill compatibility 超过大小上限") + + metadata = _parse_metadata(frontmatter.get("metadata")) + metadata_map = dict(metadata) + legacy_version = _optional_string(frontmatter, "version") + metadata_version = metadata_map.get("version") + if legacy_version is not None and metadata_version is not None and legacy_version != metadata_version: + raise ValueError("Skill 版本声明冲突") + declared_version = metadata_version or legacy_version + declared_allowed_tools = _parse_allowed_tools(frontmatter.get("allowed-tools")) + extension_field_names = tuple( + key for key in frontmatter if key not in _STANDARD_FIELDS and key not in _LEGACY_FIELDS + ) + + return SkillDocument( + name=name, + description=description, + body=body, + license=license_name, + compatibility=compatibility, + metadata=metadata, + declared_version=declared_version, + declared_allowed_tools=declared_allowed_tools, + extension_field_names=extension_field_names, + document_sha256=hashlib.sha256(normalized.encode("utf-8")).hexdigest(), + ) + + +def _split_document(document: str) -> tuple[str, str]: + lines = document.splitlines(keepends=True) + if not lines or lines[0].rstrip("\n") != "---": + raise ValueError("Skill 缺少 frontmatter") + closing_index = next( + (index for index, line in enumerate(lines[1:], start=1) if line.rstrip("\n") == "---"), + None, + ) + if closing_index is None: + raise ValueError("Skill frontmatter 未闭合") + body = "".join(lines[closing_index + 1 :]) + if not body.strip(): + raise ValueError("Skill 正文不能为空") + return "".join(lines[1:closing_index]), body + + +def _required_string(frontmatter: dict, field: str) -> str: + value = frontmatter.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Skill {field} 必须是非空字符串") + return value.strip() + + +def _optional_string(frontmatter: dict, field: str) -> str | None: + if field not in frontmatter: + return None + value = frontmatter[field] + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Skill {field} 必须是非空字符串") + return value.strip() + + +def _parse_metadata(value: object) -> tuple[tuple[str, str], ...]: + if value is None: + return () + if not isinstance(value, dict) or any(not isinstance(key, str) or not key for key in value): + raise ValueError("Skill metadata 必须是字符串键映射") + normalized: list[tuple[str, str]] = [] + for key, item in value.items(): + if isinstance(item, str): + normalized.append((key, item)) + elif isinstance(item, bool): + normalized.append((key, "true" if item else "false")) + elif isinstance(item, (int, float)): + normalized.append((key, str(item))) + else: + raise ValueError("Skill metadata 值必须是安全标量") + return tuple(normalized) + + +def _parse_allowed_tools(value: object) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + try: + tools = tuple(shlex.split(value)) + except ValueError as exc: + raise ValueError("Skill allowed-tools 字符串非法") from exc + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + tools = tuple(item.strip() for item in value) + else: + raise ValueError("Skill allowed-tools 必须是空格分隔字符串或字符串列表") + if not tools or any(not tool for tool in tools) or len(tools) != len(set(tools)): + raise ValueError("Skill allowed-tools 不能为空或重复") + return tools diff --git a/app/ai/skills/registry.py b/app/ai/skills/registry.py index 9dff283d..505c7fba 100644 --- a/app/ai/skills/registry.py +++ b/app/ai/skills/registry.py @@ -1,4 +1,4 @@ -"""从代码固定目录加载并冻结 Run 级 Skill 快照。""" +"""将本地 Agent Skills 标准目录适配为 Run 级冻结快照。""" from __future__ import annotations @@ -9,6 +9,7 @@ from time import perf_counter from typing import Literal, Sequence +from app.ai.skills.document import MAX_SKILL_FILE_BYTES, SkillDocument, parse_skill_document from app.utils.run_capability_contract import ( CAPABILITY_CANONICAL_EXTERNAL_TOOL_ORDER, CAPABILITY_CONTROL_TOOL_NAMES, @@ -18,7 +19,6 @@ SkillActivationSource = Literal["capability_package"] SkillLoadErrorCode = Literal["skill_load_failed"] -MAX_SKILL_FILE_BYTES = 32 * 1024 _SKILLS_ROOT = Path(__file__).resolve().parent _PACKAGE_SKILLS: dict[str, tuple[tuple[str, str, str], ...]] = { "verified_web": ( @@ -29,10 +29,9 @@ ), ), } -_FRONTMATTER_FIELDS = frozenset({"name", "version", "description", "allowed-tools"}) _KNOWN_TOOL_NAMES = frozenset(CAPABILITY_CANONICAL_EXTERNAL_TOOL_ORDER) _SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$") _SHA256_RE = re.compile(r"^[0-9a-f]{64}$") @@ -77,15 +76,6 @@ class SkillLoadResult: loaded_skills: tuple[LoadedSkillSnapshot, ...] -@dataclass(frozen=True, slots=True) -class _ParsedFrontmatter: - name: str - version: str - description: str - allowed_tool_names: tuple[str, ...] - body: str - - def load_skills_for_package( package_id: str, routed_tool_names: Sequence[str], @@ -152,35 +142,19 @@ def _load_skill( expected_content_sha256: str, routed_tool_names: tuple[str, ...], ) -> LoadedSkillSnapshot: - skill_path = root / skill_id / version / "SKILL.md" - resolved_path = skill_path.resolve(strict=True) - if not resolved_path.is_relative_to(root): - raise ValueError("Skill 路径越界") - if any(path.is_symlink() for path in (skill_path, skill_path.parent, skill_path.parent.parent)): - raise ValueError("Skill 路径不得使用符号链接") - - raw = resolved_path.read_bytes() - if len(raw) > MAX_SKILL_FILE_BYTES: - raise ValueError("Skill 文件超过大小上限") - document = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") - parsed = _parse_skill_document(document) - _validate_skill_metadata( - parsed, - expected_skill_id=skill_id, - expected_version=version, - routed_tool_names=routed_tool_names, - ) + parsed = _read_selected_skill(root=root, skill_id=skill_id, version=version) + allowed_tool_names = _validate_skill_metadata(parsed, routed_tool_names=routed_tool_names) content_sha256 = hashlib.sha256(parsed.body.encode("utf-8")).hexdigest() if content_sha256 != expected_content_sha256: raise ValueError("Skill 正文摘要与发布版本不一致") metadata = SkillMetadata( skill_id=parsed.name, - version=parsed.version, + version=parsed.resolved_version, description=parsed.description, content_sha256=content_sha256, - allowed_tool_names=parsed.allowed_tool_names, + allowed_tool_names=allowed_tool_names, activation_source="capability_package", - section_id=f"skill:{parsed.name}@{parsed.version}", + section_id=f"skill:{parsed.name}@{parsed.resolved_version}", char_count=len(parsed.body), ) return LoadedSkillSnapshot(metadata=metadata, content=parsed.body) @@ -208,91 +182,53 @@ def _resolve_release_pins( return tuple(releases) -def _parse_skill_document(document: str) -> _ParsedFrontmatter: - lines = document.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - raise ValueError("Skill 缺少 frontmatter") - closing_index = next( - (index for index, line in enumerate(lines[1:], start=1) if line.rstrip("\r\n") == "---"), - None, +def _read_selected_skill(*, root: Path, skill_id: str, version: str) -> SkillDocument: + candidates = ( + root / skill_id / "SKILL.md", + root / skill_id / version / "SKILL.md", ) - if closing_index is None: - raise ValueError("Skill frontmatter 未闭合") - - metadata_lines = [line.rstrip("\r\n") for line in lines[1:closing_index]] - body = "".join(lines[closing_index + 1 :]) - if not body.strip(): - raise ValueError("Skill 正文不能为空") - - scalars: dict[str, str] = {} - allowed_tools: list[str] | None = None - active_list: str | None = None - for line in metadata_lines: - if not line.strip(): + found_version_mismatch = False + for skill_path in candidates: + if not skill_path.exists(): continue - if line.startswith(" - "): - if active_list != "allowed-tools" or allowed_tools is None: - raise ValueError("Skill frontmatter 列表结构非法") - tool_name = line[4:].strip() - if not tool_name: - raise ValueError("Skill 工具名不能为空") - allowed_tools.append(tool_name) + resolved_path = skill_path.resolve(strict=True) + if not resolved_path.is_relative_to(root): + raise ValueError("Skill 路径越界") + current = skill_path + while current != root: + if current.is_symlink(): + raise ValueError("Skill 路径不得使用符号链接") + current = current.parent + + raw = resolved_path.read_bytes() + if len(raw) > MAX_SKILL_FILE_BYTES: + raise ValueError("Skill 文件超过大小上限") + document = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + parsed = parse_skill_document(document, expected_skill_id=skill_id) + if parsed.resolved_version != version: + found_version_mismatch = True continue - if line[:1].isspace() or ":" not in line: - raise ValueError("Skill frontmatter 结构非法") - field, value = line.split(":", 1) - field = field.strip() - value = value.strip() - if ( - field not in _FRONTMATTER_FIELDS - or field in scalars - or (field == "allowed-tools" and allowed_tools is not None) - ): - raise ValueError("Skill frontmatter 字段非法") - active_list = None - if field == "allowed-tools": - if value: - raise ValueError("allowed-tools 必须使用受控列表格式") - allowed_tools = [] - active_list = field - else: - if not value: - raise ValueError("Skill frontmatter 标量不能为空") - scalars[field] = value - - required_scalars = {"name", "version", "description"} - if set(scalars) != required_scalars or allowed_tools is None: - raise ValueError("Skill frontmatter 缺少必填字段") - return _ParsedFrontmatter( - name=scalars["name"], - version=scalars["version"], - description=scalars["description"], - allowed_tool_names=tuple(allowed_tools), - body=body, - ) + return parsed + if found_version_mismatch: + raise ValueError("Skill version 与发布版本不一致") + raise FileNotFoundError("Skill 文件不存在") def _validate_skill_metadata( - parsed: _ParsedFrontmatter, + parsed: SkillDocument, *, - expected_skill_id: str, - expected_version: str, routed_tool_names: tuple[str, ...], -) -> None: - if _SKILL_ID_RE.fullmatch(parsed.name) is None or parsed.name != expected_skill_id: - raise ValueError("Skill name 与受控目录不一致") - if _VERSION_RE.fullmatch(parsed.version) is None or parsed.version != expected_version: - raise ValueError("Skill version 与版本目录不一致") - if len(parsed.description) > 1024: - raise ValueError("Skill description 超过大小上限") - if not parsed.allowed_tool_names or len(parsed.allowed_tool_names) != len(set(parsed.allowed_tool_names)): - raise ValueError("Skill allowed-tools 不能为空或重复") - if CAPABILITY_CONTROL_TOOL_NAMES.intersection(parsed.allowed_tool_names): +) -> tuple[str, ...]: + declared_tools = parsed.declared_allowed_tools + if not declared_tools: + return routed_tool_names + if CAPABILITY_CONTROL_TOOL_NAMES.intersection(declared_tools): raise ValueError("Skill 不得声明控制工具") - if not set(parsed.allowed_tool_names).issubset(_KNOWN_TOOL_NAMES): + if not set(declared_tools).issubset(_KNOWN_TOOL_NAMES): raise ValueError("Skill 声明了未知工具") - if parsed.allowed_tool_names != routed_tool_names: + if declared_tools != routed_tool_names: raise ValueError("Skill allowed-tools 与能力路由工具不一致") + return routed_tool_names def _validate_routed_tool_names(routed_tool_names: Sequence[str]) -> tuple[str, ...]: diff --git a/app/ai/skills/verified-research/1.0.0/SKILL.md b/app/ai/skills/verified-research/SKILL.md similarity index 94% rename from app/ai/skills/verified-research/1.0.0/SKILL.md rename to app/ai/skills/verified-research/SKILL.md index ac54f07e..ee5c1323 100644 --- a/app/ai/skills/verified-research/1.0.0/SKILL.md +++ b/app/ai/skills/verified-research/SKILL.md @@ -1,10 +1,9 @@ --- name: verified-research -version: 1.0.0 description: 对需要官方原文与交叉来源的请求建立可核验证据链 -allowed-tools: - - web_search - - url_read +metadata: + version: "1.0.0" +allowed-tools: web_search url_read --- # 可核验证据研究 diff --git a/app/schemas/trajectory.py b/app/schemas/trajectory.py index c8eb59f9..9f2f1efa 100644 --- a/app/schemas/trajectory.py +++ b/app/schemas/trajectory.py @@ -74,7 +74,7 @@ class TrajectorySkillMetadata(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) skill_id: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", max_length=128) - version: str = Field(pattern=r"^\d+\.\d+\.\d+$", max_length=32) + version: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$", max_length=64) content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") allowed_tool_names: list[str] = Field(min_length=1, max_length=3) section_id: str = Field(min_length=1, max_length=192) diff --git a/requirements.txt b/requirements.txt index 8cc60cec..36eb6902 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ watchfiles==1.0.4 authlib==1.3.2 websockets==15.0 python-dotenv==1.1.0 +PyYAML==6.0.2 mcp==1.28.1 # MCP 1.28.1 允许浮动到 sse-starlette 3.x,但 3.4+ 要求 Starlette >=0.49, # 与 FastAPI 0.115.12 的 Starlette <0.47 约束冲突,因此固定兼容版本。 diff --git a/test/ai/skills/test_document.py b/test/ai/skills/test_document.py new file mode 100644 index 00000000..dc95b33e --- /dev/null +++ b/test/ai/skills/test_document.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import textwrap + +import pytest + +from app.ai.skills.document import parse_skill_document + + +def test_agent_skills_minimal_document_only_requires_name_and_description() -> None: + parsed = parse_skill_document( + textwrap.dedent( + """\ + --- + name: template-skill + description: Replace with description of the skill and when the agent should use it. + --- + + # Insert instructions below + """ + ), + expected_skill_id="template-skill", + ) + + assert parsed.name == "template-skill" + assert parsed.description.startswith("Replace with description") + assert parsed.license is None + assert parsed.compatibility is None + assert parsed.metadata == () + assert parsed.declared_version is None + assert parsed.declared_allowed_tools == () + assert parsed.body == "\n# Insert instructions below\n" + + +def test_agent_skills_optional_fields_and_metadata_are_preserved() -> None: + parsed = parse_skill_document( + textwrap.dedent( + """\ + --- + name: vercel-react-best-practices + description: React and Next.js performance optimization guidelines. + license: MIT + compatibility: Requires Node.js 22+ and network access + metadata: + author: vercel + version: "1.0.0" + --- + + # Vercel React Best Practices + """ + ), + expected_skill_id="vercel-react-best-practices", + ) + + assert parsed.license == "MIT" + assert parsed.compatibility == "Requires Node.js 22+ and network access" + assert parsed.metadata == (("author", "vercel"), ("version", "1.0.0")) + assert parsed.declared_version == "1.0.0" + + +def test_community_metadata_scalars_are_normalized_without_becoming_runtime_permissions() -> None: + parsed = parse_skill_document( + """--- +name: internal-preview +description: A community skill that uses scalar metadata extensions. +metadata: + internal: true + priority: 3 +--- +# Preview +""", + expected_skill_id="internal-preview", + ) + + assert parsed.metadata == (("internal", "true"), ("priority", "3")) + assert parsed.declared_allowed_tools == () + + +def test_official_allowed_tools_string_and_legacy_list_are_both_normalized() -> None: + official = parse_skill_document( + """--- +name: shell-review +description: Reviews a repository with pre-approved shell and read tools. +allowed-tools: Bash(git:*) Bash(jq:*) Read +--- +# Review +""", + expected_skill_id="shell-review", + ) + legacy = parse_skill_document( + """--- +name: verified-research +version: 1.0.0 +description: 对需要官方原文与交叉来源的请求建立可核验证据链 +allowed-tools: + - web_search + - url_read +--- +# 可核验研究 +""", + expected_skill_id="verified-research", + ) + + assert official.declared_allowed_tools == ("Bash(git:*)", "Bash(jq:*)", "Read") + assert official.declared_version is None + assert legacy.declared_allowed_tools == ("web_search", "url_read") + assert legacy.declared_version == "1.0.0" + + +@pytest.mark.parametrize( + ("case_name", "document"), + [ + ( + "duplicate_key", + """--- +name: duplicated +name: replaced +description: Duplicate keys must not be silently overwritten. +--- +# Body +""", + ), + ( + "unsafe_yaml_tag", + """--- +name: unsafe-tag +description: !!python/object/apply:os.system [echo unsafe] +--- +# Body +""", + ), + ( + "metadata_nested_value", + """--- +name: nested-metadata +description: Metadata follows the Agent Skills string map contract. +metadata: + nested: + enabled: true +--- +# Body +""", + ), + ( + "empty_body", + """--- +name: empty-body +description: Empty instructions are invalid. +--- +""", + ), + ], +) +def test_invalid_or_unsafe_frontmatter_is_rejected(case_name: str, document: str) -> None: + with pytest.raises(ValueError, match="Skill"): + parse_skill_document(document) + + +def test_name_must_match_parent_directory_identity() -> None: + with pytest.raises(ValueError, match="目录"): + parse_skill_document( + """--- +name: actual-name +description: Name must match the directory selected by the registry. +--- +# Body +""", + expected_skill_id="different-name", + ) diff --git a/test/ai/skills/test_registry.py b/test/ai/skills/test_registry.py index 4376c83e..c194be21 100644 --- a/test/ai/skills/test_registry.py +++ b/test/ai/skills/test_registry.py @@ -23,16 +23,23 @@ def _skill_document( allowed_tools: tuple[str, ...] | None = ("web_search", "url_read"), body: str = VALID_BODY, extra_frontmatter: str = "", + standard_layout: bool = False, ) -> bytes: lines = [ "---", f"name: {name}", - f"version: {version}", f"description: {description}", ] + if standard_layout: + lines.extend(["metadata:", f' version: "{version}"']) + else: + lines.append(f"version: {version}") if allowed_tools is not None: - lines.append("allowed-tools:") - lines.extend(f" - {tool_name}" for tool_name in allowed_tools) + if standard_layout: + lines.append(f"allowed-tools: {' '.join(allowed_tools)}") + else: + lines.append("allowed-tools:") + lines.extend(f" - {tool_name}" for tool_name in allowed_tools) if extra_frontmatter: lines.append(extra_frontmatter) lines.extend(["---", body]) @@ -51,6 +58,13 @@ def _write_skill( return skill_path +def _write_standard_skill(root: Path, *, payload: bytes) -> Path: + skill_path = root / "verified-research" / "SKILL.md" + skill_path.parent.mkdir(parents=True, exist_ok=True) + skill_path.write_bytes(payload) + return skill_path + + def test_bundled_verified_research_skill_loads_as_frozen_snapshot() -> None: result = load_skills_for_package( "verified_web", @@ -85,12 +99,63 @@ def test_bundled_verified_research_skill_loads_as_frozen_snapshot() -> None: assert loaded.content not in serialized +def test_standard_directory_layout_and_frontmatter_load_through_existing_run_contract(tmp_path: Path) -> None: + _write_standard_skill( + tmp_path, + payload=_skill_document(standard_layout=True), + ) + + result = load_skills_for_package( + "verified_web", + ("web_search", "url_read"), + skills_root=tmp_path, + release_pins=( + SkillReleasePin( + skill_id="verified-research", + version="1.0.0", + content_sha256=hashlib.sha256(VALID_BODY.encode("utf-8")).hexdigest(), + ), + ), + ) + + assert result.resolution.status == "loaded" + assert result.loaded_skills[0].metadata.version == "1.0.0" + assert result.loaded_skills[0].metadata.allowed_tool_names == ("web_search", "url_read") + + +def test_standard_skill_without_version_or_allowed_tools_gets_content_pin_and_route_permissions(tmp_path: Path) -> None: + payload = _skill_document(version="ignored", allowed_tools=None, standard_layout=True).replace( + b'metadata:\n version: "ignored"\n', + b"", + ) + _write_standard_skill(tmp_path, payload=payload) + normalized = payload.decode("utf-8") + resolved_version = f"sha256-{hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:16]}" + + result = load_skills_for_package( + "verified_web", + ("web_search", "url_read"), + skills_root=tmp_path, + release_pins=( + SkillReleasePin( + skill_id="verified-research", + version=resolved_version, + content_sha256=hashlib.sha256(VALID_BODY.encode("utf-8")).hexdigest(), + ), + ), + ) + + assert result.resolution.status == "loaded" + assert result.resolution.skills[0].version == resolved_version + assert result.resolution.skills[0].allowed_tool_names == ("web_search", "url_read") + + @pytest.mark.parametrize("line_ending", (b"\r\n", b"\r")) def test_platform_line_endings_keep_published_body_digest_stable( tmp_path: Path, line_ending: bytes, ) -> None: - bundled_path = Path(__file__).parents[3] / "app/ai/skills/verified-research/1.0.0/SKILL.md" + bundled_path = Path(__file__).parents[3] / "app/ai/skills/verified-research/SKILL.md" bundled = bundled_path.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") _write_skill(tmp_path, payload=bundled.replace(b"\n", line_ending)) @@ -189,12 +254,6 @@ def test_unmapped_package_does_not_touch_skill_files(package_id: str, tmp_path: [ ("invalid_utf8", b"---\nname: verified-research\n---\n\xff", "1.0.0", ("web_search", "url_read")), ("missing_frontmatter", b"# no frontmatter", "1.0.0", ("web_search", "url_read")), - ( - "unknown_frontmatter_field", - _skill_document(extra_frontmatter="license: proprietary"), - "1.0.0", - ("web_search", "url_read"), - ), ( "version_directory_mismatch", _skill_document(version="1.0.1"), @@ -207,12 +266,6 @@ def test_unmapped_package_does_not_touch_skill_files(package_id: str, tmp_path: "1.0.0", ("web_search", "url_read"), ), - ( - "missing_allowed_tools", - _skill_document(allowed_tools=None), - "1.0.0", - ("web_search", "url_read"), - ), ( "empty_allowed_tools", _skill_document(allowed_tools=()), diff --git a/test/services/agent/test_events.py b/test/services/agent/test_events.py index 8675e1ee..37b65944 100644 --- a/test/services/agent/test_events.py +++ b/test/services/agent/test_events.py @@ -457,6 +457,22 @@ def test_skills_resolved_accepts_three_terminal_states_and_rejects_body_or_incon event = adapter.validate_python(loaded) self.assertIsInstance(event, SkillsResolved) self.assertEqual(event.skills[0].content_sha256, "b" * 64) + + content_version = "sha256-0123456789abcdef" + content_pinned = adapter.validate_python( + { + **loaded, + "skills": [ + { + **SKILL_METADATA, + "version": content_version, + "section_id": f"skill:verified-research@{content_version}", + } + ], + } + ) + self.assertEqual(content_pinned.skills[0].version, content_version) + for payload in ( {**loaded, "skills": [{**SKILL_METADATA, "content": "正文禁止进入事件"}]}, {**loaded, "status": "not_selected"}, From 16e66b5e4d2fdd36b0165d5135b8ad28a0c3fedb Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Mon, 31 Aug 2026 16:54:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20Skill=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=AD=90=E7=9B=AE=E5=BD=95=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动:Registry 只读取标准的 /SKILL.md;删除版本子目录探测与相关测试夹具,并同步修正规格和实施计划。 验证:全量测试 3569 项、1917 个子测试通过,2 项跳过;架构检查、ruff check、定向格式检查与 git diff --check 通过。 Co-Authored-By: Codex --- app/ai/skills/registry.py | 47 ++++++--------- .../plans/2026-08-31-run-skills-mvp.md | 6 +- .../specs/2026-08-31-run-skills-mvp.md | 22 +++---- test/ai/skills/test_registry.py | 59 ++++++++++--------- 4 files changed, 63 insertions(+), 71 deletions(-) diff --git a/app/ai/skills/registry.py b/app/ai/skills/registry.py index 505c7fba..76737919 100644 --- a/app/ai/skills/registry.py +++ b/app/ai/skills/registry.py @@ -83,7 +83,7 @@ def load_skills_for_package( skills_root: Path | None = None, release_pins: Sequence[SkillReleasePin] | None = None, ) -> SkillLoadResult: - """加载代码固定 Skill;已选择 Skill 的任何异常都返回受控失败。""" + """加载能力包配置的标准目录 Skill;已选择 Skill 的异常均受控失败。""" started = perf_counter() configured_releases = _PACKAGE_SKILLS.get(package_id, ()) @@ -183,35 +183,24 @@ def _resolve_release_pins( def _read_selected_skill(*, root: Path, skill_id: str, version: str) -> SkillDocument: - candidates = ( - root / skill_id / "SKILL.md", - root / skill_id / version / "SKILL.md", - ) - found_version_mismatch = False - for skill_path in candidates: - if not skill_path.exists(): - continue - resolved_path = skill_path.resolve(strict=True) - if not resolved_path.is_relative_to(root): - raise ValueError("Skill 路径越界") - current = skill_path - while current != root: - if current.is_symlink(): - raise ValueError("Skill 路径不得使用符号链接") - current = current.parent - - raw = resolved_path.read_bytes() - if len(raw) > MAX_SKILL_FILE_BYTES: - raise ValueError("Skill 文件超过大小上限") - document = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") - parsed = parse_skill_document(document, expected_skill_id=skill_id) - if parsed.resolved_version != version: - found_version_mismatch = True - continue - return parsed - if found_version_mismatch: + skill_path = root / skill_id / "SKILL.md" + resolved_path = skill_path.resolve(strict=True) + if not resolved_path.is_relative_to(root): + raise ValueError("Skill 路径越界") + current = skill_path + while current != root: + if current.is_symlink(): + raise ValueError("Skill 路径不得使用符号链接") + current = current.parent + + raw = resolved_path.read_bytes() + if len(raw) > MAX_SKILL_FILE_BYTES: + raise ValueError("Skill 文件超过大小上限") + document = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + parsed = parse_skill_document(document, expected_skill_id=skill_id) + if parsed.resolved_version != version: raise ValueError("Skill version 与发布版本不一致") - raise FileNotFoundError("Skill 文件不存在") + return parsed def _validate_skill_metadata( diff --git a/docs/superpowers/plans/2026-08-31-run-skills-mvp.md b/docs/superpowers/plans/2026-08-31-run-skills-mvp.md index 798ef049..c0dd04bb 100644 --- a/docs/superpowers/plans/2026-08-31-run-skills-mvp.md +++ b/docs/superpowers/plans/2026-08-31-run-skills-mvp.md @@ -25,16 +25,16 @@ **Files:** - Create: `app/ai/skills/__init__.py` - Create: `app/ai/skills/registry.py` -- Create: `app/ai/skills/verified-research/1.0.0/SKILL.md` +- Create: `app/ai/skills/verified-research/SKILL.md` - Test: `test/ai/skills/test_registry.py` **Interfaces:** - Produces: `LoadedSkillSnapshot`、`RunSkillResolution`、`load_skills_for_package(package_id, routed_tool_names)`。 - `LoadedSkillSnapshot` 含安全元数据和仅内存 `content`;`RunSkillResolution` 可序列化但不含正文。 -- [ ] 写失败测试:合法文件稳定解析;未选择不读取文件;非法 UTF-8/frontmatter/版本/allowed-tools/超限/路径逃逸返回 `load_failed`。 +- [ ] 写失败测试:标准目录合法文件稳定解析;版本子目录不得加载;未选择不读取文件;非法 UTF-8/frontmatter/版本/allowed-tools/超限/路径逃逸返回 `load_failed`。 - [ ] 运行 `/Users/sean/code/fusion/fusion-api/.venv/bin/python -m pytest test/ai/skills/test_registry.py -q`,确认因模块或接口不存在失败。 -- [ ] 实现受控 frontmatter 解析、32 KiB 上限、SHA-256、版本目录校验、固定 package 映射和 fail-closed 结果。 +- [ ] 实现标准 frontmatter 解析、32 KiB 上限、SHA-256、单一标准目录、固定 package 映射和 fail-closed 结果。 - [ ] 重跑单文件测试至通过,并运行目标 Ruff。 ### Task 2: Run 能力包、工具权限与 Prompt 原子组装 diff --git a/docs/superpowers/specs/2026-08-31-run-skills-mvp.md b/docs/superpowers/specs/2026-08-31-run-skills-mvp.md index 454ac5ca..f32196ee 100644 --- a/docs/superpowers/specs/2026-08-31-run-skills-mvp.md +++ b/docs/superpowers/specs/2026-08-31-run-skills-mvp.md @@ -6,16 +6,16 @@ ## DeerFlow 参考与取舍 -保留 DeerFlow 的 `SKILL.md` 包结构、frontmatter、版本、内容哈希、渐进加载、`allowed-tools`、fail closed 和历史可还原原则。Fusion 不复制 `skill_index → describe_skill → read_file`:现有路由已经能在首次 LLM 前高置信选择能力包,增加两轮模型工具调用会扩大延迟、竞态和权限面。 +保留 Agent Skills 的 `SKILL.md` 包结构、frontmatter、内容哈希、渐进加载、`allowed-tools` 和 fail closed 原则。Fusion 不复制 `skill_index → describe_skill → read_file`:现有路由已经能在首次 LLM 前高置信选择能力包,增加两轮模型工具调用会扩大延迟、竞态和权限面。 不实现用户 Skill、在线编辑、SkillScan、脚本执行、Secret 注入、沙箱投影、热重载、Slash 激活或线程级 `skill_context`。这些能力达到明确产品需求后再单独设计。 -## 目录与版本 +## 标准目录与运行时版本 首个 Skill 位于: ```text -app/ai/skills/verified-research/1.0.0/SKILL.md +app/ai/skills/verified-research/SKILL.md ``` 文件使用受控 frontmatter: @@ -23,19 +23,19 @@ app/ai/skills/verified-research/1.0.0/SKILL.md ```yaml --- name: verified-research -version: 1.0.0 description: 对需要官方原文与交叉来源的请求建立可核验证据链 -allowed-tools: - - web_search - - url_read +metadata: + version: "1.0.0" +allowed-tools: web_search url_read --- ``` -- `allowed-tools` 必填;省略、空数组、重复、控制工具或未知工具均加载失败。 -- 版本目录与 frontmatter 必须一致。已发布版本不得原地修改;正文变化新建版本目录。 +- 只读取 `/SKILL.md`,不探测或兼容 `//SKILL.md`。 +- `allowed-tools` 按 Agent Skills 标准为可选字段;省略时沿用能力路由已授权工具,声明时只能与路由授权集合一致,不能扩权。 +- `metadata.version` 可选;省略或格式不适合账本时使用整份 `SKILL.md` 的 SHA-256 生成稳定运行时版本。 - 能力包发布映射同时固定 `skill_id + version + content_sha256`;磁盘正文与固定摘要不一致时按加载失败处理。 - 正文使用 UTF-8,包含 frontmatter 的完整文件不超过 32 KiB。 -- 不引入 PyYAML 运行时依赖;MVP 解析器只接受上述受控字段和结构。 +- 使用 PyYAML SafeLoader 解析标准 frontmatter,并拒绝危险标签、YAML 别名和重复键。 ## Run 冻结与原子权限 @@ -78,7 +78,7 @@ content `verified-research` 接管现有 `verified_research_plan` 的研究方法、来源核验与输出规则;旧文本段落停止注入,避免两套规则和版本漂移。服务端的 `web_search × 1`、`url_read × 2`、计划依赖及证据校验继续作为确定性门禁,不迁入 Skill。 -每个 Run 重新选择 Skill。话题切换和重试不从上一 Run 隐式继承“当前活动 Skill”;continuation 按 owner-scoped previous Run 已持久化的 ID、版本和哈希精确恢复同一业务尝试,找不到原版本或当前路由无法继续承接时 fail closed。 +每个 Run 重新选择 Skill。话题切换和重试不从上一 Run 隐式继承“当前活动 Skill”;continuation 按 owner-scoped previous Run 已持久化的 ID、版本和哈希校验当前标准目录文件,文件已经更新或当前路由无法继续承接时 fail closed。历史详情始终读取当次 Prompt snapshot,不从磁盘重建。 ## 协议与 Trajectory diff --git a/test/ai/skills/test_registry.py b/test/ai/skills/test_registry.py index c194be21..113ee28e 100644 --- a/test/ai/skills/test_registry.py +++ b/test/ai/skills/test_registry.py @@ -49,19 +49,11 @@ def _skill_document( def _write_skill( root: Path, *, - version_directory: str = "1.0.0", payload: bytes | None = None, ) -> Path: - skill_path = root / "verified-research" / version_directory / "SKILL.md" - skill_path.parent.mkdir(parents=True, exist_ok=True) - skill_path.write_bytes(payload if payload is not None else _skill_document()) - return skill_path - - -def _write_standard_skill(root: Path, *, payload: bytes) -> Path: skill_path = root / "verified-research" / "SKILL.md" skill_path.parent.mkdir(parents=True, exist_ok=True) - skill_path.write_bytes(payload) + skill_path.write_bytes(payload if payload is not None else _skill_document()) return skill_path @@ -100,7 +92,7 @@ def test_bundled_verified_research_skill_loads_as_frozen_snapshot() -> None: def test_standard_directory_layout_and_frontmatter_load_through_existing_run_contract(tmp_path: Path) -> None: - _write_standard_skill( + _write_skill( tmp_path, payload=_skill_document(standard_layout=True), ) @@ -123,12 +115,34 @@ def test_standard_directory_layout_and_frontmatter_load_through_existing_run_con assert result.loaded_skills[0].metadata.allowed_tool_names == ("web_search", "url_read") +def test_versioned_subdirectory_is_not_a_skill_source(tmp_path: Path) -> None: + legacy_path = tmp_path / "verified-research" / "1.0.0" / "SKILL.md" + legacy_path.parent.mkdir(parents=True) + legacy_path.write_bytes(_skill_document(standard_layout=True)) + + result = load_skills_for_package( + "verified_web", + ("web_search", "url_read"), + skills_root=tmp_path, + release_pins=( + SkillReleasePin( + skill_id="verified-research", + version="1.0.0", + content_sha256=hashlib.sha256(VALID_BODY.encode("utf-8")).hexdigest(), + ), + ), + ) + + assert result.resolution.status == "load_failed" + assert result.loaded_skills == () + + def test_standard_skill_without_version_or_allowed_tools_gets_content_pin_and_route_permissions(tmp_path: Path) -> None: payload = _skill_document(version="ignored", allowed_tools=None, standard_layout=True).replace( b'metadata:\n version: "ignored"\n', b"", ) - _write_standard_skill(tmp_path, payload=payload) + _write_skill(tmp_path, payload=payload) normalized = payload.decode("utf-8") resolved_version = f"sha256-{hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:16]}" @@ -190,7 +204,6 @@ def test_continuation_release_pin_restores_exact_version_and_digest(tmp_path: Pa body = "# 旧版可核验研究\n\n继续同一业务尝试时必须恢复这份正文。\n" _write_skill( tmp_path, - version_directory="0.9.0", payload=_skill_document(version="0.9.0", body=body), ) pin = SkillReleasePin( @@ -250,62 +263,53 @@ def test_unmapped_package_does_not_touch_skill_files(package_id: str, tmp_path: @pytest.mark.parametrize( - ("case_name", "payload", "version_directory", "routed_tool_names"), + ("case_name", "payload", "routed_tool_names"), [ - ("invalid_utf8", b"---\nname: verified-research\n---\n\xff", "1.0.0", ("web_search", "url_read")), - ("missing_frontmatter", b"# no frontmatter", "1.0.0", ("web_search", "url_read")), + ("invalid_utf8", b"---\nname: verified-research\n---\n\xff", ("web_search", "url_read")), + ("missing_frontmatter", b"# no frontmatter", ("web_search", "url_read")), ( - "version_directory_mismatch", + "declared_version_mismatch", _skill_document(version="1.0.1"), - "1.0.0", ("web_search", "url_read"), ), ( "invalid_version", _skill_document(version="v1"), - "1.0.0", ("web_search", "url_read"), ), ( "empty_allowed_tools", _skill_document(allowed_tools=()), - "1.0.0", ("web_search", "url_read"), ), ( "duplicate_allowed_tool", _skill_document(allowed_tools=("web_search", "web_search")), - "1.0.0", ("web_search", "url_read"), ), ( "control_tool", _skill_document(allowed_tools=("web_search", "url_read", "update_plan")), - "1.0.0", ("web_search", "url_read"), ), ( "unknown_tool", _skill_document(allowed_tools=("web_search", "url_read", "shell_exec")), - "1.0.0", ("web_search", "url_read"), ), ( "expands_routed_tools", _skill_document(), - "1.0.0", ("web_search",), ), ( "missing_required_tool", _skill_document(allowed_tools=("web_search",)), - "1.0.0", ("web_search", "url_read"), ), ( "oversized_file", _skill_document(body="x" * (32 * 1024)), - "1.0.0", ("web_search", "url_read"), ), ], @@ -313,11 +317,10 @@ def test_unmapped_package_does_not_touch_skill_files(package_id: str, tmp_path: def test_invalid_selected_skill_fails_closed( case_name: str, payload: bytes, - version_directory: str, routed_tool_names: tuple[str, ...], tmp_path: Path, ) -> None: - _write_skill(tmp_path, version_directory=version_directory, payload=payload) + _write_skill(tmp_path, payload=payload) result = load_skills_for_package( "verified_web", @@ -350,7 +353,7 @@ def test_missing_selected_skill_fails_closed(tmp_path: Path) -> None: def test_selected_skill_symlink_cannot_escape_registry_root(tmp_path: Path) -> None: outside = tmp_path.parent / f"{tmp_path.name}-outside-skill.md" outside.write_bytes(_skill_document()) - skill_path = tmp_path / "verified-research" / "1.0.0" / "SKILL.md" + skill_path = tmp_path / "verified-research" / "SKILL.md" skill_path.parent.mkdir(parents=True) skill_path.symlink_to(outside)