mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
Merge pull request #94 from hhzscreate/main
skill解析能力适配:skill加载改为yaml解析器,写入使用safe_dump
This commit is contained in:
+13
-35
@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, status
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.modules.agent.skills import SkillsLoader
|
||||
from backend.modules.agent.skills import SkillsLoader, build_frontmatter
|
||||
from backend.modules.agent.skills_config import SkillConfigManager
|
||||
from backend.modules.agent.skills_schema import SkillConfigSchema
|
||||
from backend.modules.config.loader import config_loader
|
||||
@@ -455,24 +455,13 @@ async def create_skill(request: CreateSkillRequest, req: Request) -> SkillDetail
|
||||
)
|
||||
|
||||
# 构建技能内容(包含 frontmatter)
|
||||
metadata = {
|
||||
"CountBot": {
|
||||
"always": request.auto_load,
|
||||
"requires": {
|
||||
"bins": request.requirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frontmatter = f"""---
|
||||
name: {request.name}
|
||||
description: {request.description}
|
||||
metadata: {json.dumps(metadata)}
|
||||
---
|
||||
full_content = build_frontmatter(
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
auto_load=request.auto_load,
|
||||
requirements=request.requirements,
|
||||
) + request.content
|
||||
|
||||
"""
|
||||
full_content = frontmatter + request.content
|
||||
|
||||
# 创建技能
|
||||
success = skills_loader.add_skill(request.name, full_content)
|
||||
|
||||
@@ -521,24 +510,13 @@ async def update_skill(name: str, request: UpdateSkillRequest, req: Request) ->
|
||||
ensure_workspace_skill(skill, "update")
|
||||
|
||||
# 构建技能内容(包含 frontmatter)
|
||||
metadata = {
|
||||
"CountBot": {
|
||||
"always": request.auto_load,
|
||||
"requires": {
|
||||
"bins": request.requirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frontmatter = f"""---
|
||||
name: {name}
|
||||
description: {request.description}
|
||||
metadata: {json.dumps(metadata)}
|
||||
---
|
||||
full_content = build_frontmatter(
|
||||
name=name,
|
||||
description=request.description,
|
||||
auto_load=request.auto_load,
|
||||
requirements=request.requirements,
|
||||
) + request.content
|
||||
|
||||
"""
|
||||
full_content = frontmatter + request.content
|
||||
|
||||
# 更新技能
|
||||
success = skills_loader.update_skill(name, full_content)
|
||||
|
||||
|
||||
+235
-37
@@ -7,12 +7,95 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from backend.utils.paths import APPLICATION_ROOT
|
||||
|
||||
# 默认内置技能目录
|
||||
BUILTIN_SKILLS_DIR = APPLICATION_ROOT / "workspace" / "skills"
|
||||
|
||||
# frontmatter 分隔符。兼容 CRLF(Windows 上编辑过的 SKILL.md 会是 \r\n)与结尾无换行的文件。
|
||||
_FRONTMATTER_RE = re.compile(r"^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)", re.DOTALL)
|
||||
|
||||
# Agent Skills 开放标准:description 上限 1024 字符,name 为 kebab-case。
|
||||
# 超限只告警不拒绝——宁可技能可用但有提示,也不要静默消失。
|
||||
MAX_DESCRIPTION_LENGTH = 1024
|
||||
MAX_NAME_LENGTH = 64
|
||||
_SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> bool:
|
||||
"""YAML 里 `always: true` 是真 bool,但手写的 `always: "yes"` 是字符串,两种都要认。"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "yes", "1", "on")
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_str_list(value: Any) -> List[str]:
|
||||
"""接受 YAML 列表,也接受逗号分隔的单行字符串(Claude Code 生态两种写法都有)。"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _coerce_requires(value: Any) -> Dict[str, List[str]]:
|
||||
"""规范化 requires 为 {bins: [...], env: [...]}。"""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
requires: Dict[str, List[str]] = {}
|
||||
for key in ("bins", "env"):
|
||||
items = _coerce_str_list(value.get(key))
|
||||
if items:
|
||||
requires[key] = items
|
||||
return requires
|
||||
|
||||
|
||||
def _extract_frontmatter(content: str) -> Optional[str]:
|
||||
"""取出 frontmatter 原文;没有 frontmatter 返回 None。"""
|
||||
if not content.startswith("---"):
|
||||
return None
|
||||
match = _FRONTMATTER_RE.match(content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def build_frontmatter(
|
||||
name: str,
|
||||
description: str,
|
||||
auto_load: bool = False,
|
||||
requirements: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""生成 SKILL.md 的 frontmatter。
|
||||
|
||||
必须用 yaml.safe_dump 而不是 f-string 拼接:描述里只要含 ": "、以 "#" 开头、
|
||||
或者是多行文本,裸拼出来就是非法 YAML,会让整个 frontmatter 解析失败。
|
||||
safe_dump 会按需自动加引号 / 转块标量。
|
||||
"""
|
||||
payload: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": {
|
||||
"CountBot": {
|
||||
"always": bool(auto_load),
|
||||
"requires": {"bins": list(requirements or [])},
|
||||
}
|
||||
},
|
||||
}
|
||||
body = yaml.safe_dump(
|
||||
payload,
|
||||
allow_unicode=True, # 中文描述不要被转义成 \uXXXX
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
)
|
||||
return f"---\n{body}---\n\n"
|
||||
|
||||
|
||||
def _is_same_or_nested_path(path: Path, base: Path) -> bool:
|
||||
"""判断 path 是否等于 base 或位于 base 之内。"""
|
||||
@@ -45,50 +128,152 @@ class Skill:
|
||||
self.auto_load = self.metadata.get("always", False)
|
||||
|
||||
def _parse_metadata(self) -> Dict[str, Any]:
|
||||
"""解析技能文件的元数据(YAML frontmatter)"""
|
||||
metadata = {
|
||||
"""解析技能文件的 YAML frontmatter。
|
||||
|
||||
用真正的 YAML 解析器。此前是手搓的 `split(":", 1)` 行循环,它读不了块标量
|
||||
(`description: |`)——遇到时会把 description 解析成字面量 "|",导致技能在
|
||||
系统提示词里没有任何触发语,永远不会被激活,而且不报错。
|
||||
|
||||
解析失败时降级到 legacy 行解析并记录 parse_error,而不是让整个技能失去元数据。
|
||||
"""
|
||||
metadata: Dict[str, Any] = {
|
||||
"title": self.name,
|
||||
"description": "",
|
||||
"dependencies": [],
|
||||
"tags": [],
|
||||
"always": False,
|
||||
"requires": {},
|
||||
# 开放标准字段:此前被解析后丢弃
|
||||
"name": "",
|
||||
"version": "",
|
||||
"license": "",
|
||||
"allowed_tools": [],
|
||||
# 诊断信息,供 API/UI 显式暴露,不再静默 warning
|
||||
"warnings": [],
|
||||
"parse_error": "",
|
||||
}
|
||||
|
||||
# 解析 YAML frontmatter
|
||||
if self.content.startswith("---"):
|
||||
match = re.match(r"^---\n(.*?)\n---", self.content, re.DOTALL)
|
||||
if match:
|
||||
yaml_content = match.group(1)
|
||||
|
||||
# 简单的 YAML 解析
|
||||
for line in yaml_content.split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"\'')
|
||||
|
||||
if key == "title":
|
||||
metadata["title"] = value
|
||||
elif key == "description":
|
||||
metadata["description"] = value
|
||||
elif key == "always":
|
||||
metadata["always"] = value.lower() in ("true", "yes", "1")
|
||||
elif key == "metadata":
|
||||
# 解析技能元数据 JSON
|
||||
try:
|
||||
meta_data = json.loads(value)
|
||||
if isinstance(meta_data, dict):
|
||||
skill_meta = meta_data.get("CountBot", {})
|
||||
if "requires" in skill_meta:
|
||||
metadata["requires"] = skill_meta["requires"]
|
||||
if "always" in skill_meta:
|
||||
metadata["always"] = skill_meta["always"]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
raw = _extract_frontmatter(self.content)
|
||||
if raw is None:
|
||||
return metadata
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as exc:
|
||||
detail = " ".join(str(exc).split())
|
||||
metadata["parse_error"] = f"YAML 解析失败: {detail[:200]}"
|
||||
# 降级:至少把能捞的 key 捞出来,技能不至于完全没有描述
|
||||
self._apply_legacy_frontmatter(raw, metadata)
|
||||
return metadata
|
||||
|
||||
if data is None:
|
||||
return metadata
|
||||
if not isinstance(data, dict):
|
||||
metadata["parse_error"] = (
|
||||
f"frontmatter 必须是 YAML 映射(key: value),实际解析出 {type(data).__name__}"
|
||||
)
|
||||
return metadata
|
||||
|
||||
self._apply_frontmatter(data, metadata)
|
||||
return metadata
|
||||
|
||||
def _apply_frontmatter(self, data: Dict[str, Any], metadata: Dict[str, Any]) -> None:
|
||||
"""把解析出的 YAML 映射投影到 metadata,并做开放标准的 lint。"""
|
||||
warnings: List[str] = metadata["warnings"]
|
||||
|
||||
if data.get("title") is not None:
|
||||
metadata["title"] = str(data["title"]).strip()
|
||||
|
||||
description = data.get("description")
|
||||
if description is not None:
|
||||
metadata["description"] = str(description).strip()
|
||||
|
||||
for key in ("name", "version", "license"):
|
||||
if data.get(key) is not None:
|
||||
metadata[key] = str(data[key]).strip()
|
||||
|
||||
metadata["tags"] = _coerce_str_list(data.get("tags"))
|
||||
metadata["dependencies"] = _coerce_str_list(data.get("dependencies"))
|
||||
|
||||
# allowed-tools(开放标准写法)与 allowed_tools 都认。
|
||||
# 注意:解析出来只是暴露给 API/UI,运行时尚未强制执行(见 S2)。
|
||||
allowed = data.get("allowed-tools")
|
||||
if allowed is None:
|
||||
allowed = data.get("allowed_tools")
|
||||
metadata["allowed_tools"] = _coerce_str_list(allowed)
|
||||
|
||||
if "always" in data:
|
||||
metadata["always"] = _coerce_bool(data["always"])
|
||||
|
||||
# 顶层 requires(原生 YAML 写法)
|
||||
metadata["requires"] = _coerce_requires(data.get("requires"))
|
||||
|
||||
# metadata: 既可能是 YAML 内联映射(API 写出来的 JSON 恰好是合法 YAML flow map),
|
||||
# 也可能是遗留的 JSON 字符串。两种都要认。
|
||||
nested = data.get("metadata")
|
||||
if isinstance(nested, str):
|
||||
try:
|
||||
nested = json.loads(nested)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
warnings.append("metadata 字段不是合法的 JSON/YAML 映射,已忽略")
|
||||
nested = None
|
||||
if isinstance(nested, dict):
|
||||
countbot_meta = nested.get("CountBot")
|
||||
if isinstance(countbot_meta, dict):
|
||||
if "requires" in countbot_meta:
|
||||
# 顶层 requires 优先;仅在其缺省时回退到嵌套写法
|
||||
nested_requires = _coerce_requires(countbot_meta.get("requires"))
|
||||
if nested_requires and not metadata["requires"]:
|
||||
metadata["requires"] = nested_requires
|
||||
if "always" in countbot_meta:
|
||||
metadata["always"] = _coerce_bool(countbot_meta["always"])
|
||||
|
||||
self._lint(data, metadata, warnings)
|
||||
|
||||
def _lint(
|
||||
self, data: Dict[str, Any], metadata: Dict[str, Any], warnings: List[str]
|
||||
) -> None:
|
||||
"""开放标准合规检查。只告警,不让技能失效。"""
|
||||
if not metadata["description"]:
|
||||
warnings.append("缺少 description,模型将无法判断何时使用该技能")
|
||||
elif len(metadata["description"]) > MAX_DESCRIPTION_LENGTH:
|
||||
warnings.append(
|
||||
f"description 长度 {len(metadata['description'])} 超过开放标准上限 "
|
||||
f"{MAX_DESCRIPTION_LENGTH}"
|
||||
)
|
||||
|
||||
declared_name = metadata["name"]
|
||||
if declared_name:
|
||||
if len(declared_name) > MAX_NAME_LENGTH:
|
||||
warnings.append(f"name 长度超过 {MAX_NAME_LENGTH} 字符")
|
||||
if not _SKILL_NAME_RE.match(declared_name):
|
||||
warnings.append(f"name '{declared_name}' 不是合法的 kebab-case")
|
||||
if declared_name != self.name:
|
||||
# 目录名才是技能身份(.skills_config.json / API 路由 / 读文件拦截都以它为键)。
|
||||
# 这里只提示,不改身份——改身份是破坏性变更。
|
||||
warnings.append(
|
||||
f"frontmatter name '{declared_name}' 与目录名 '{self.name}' 不一致;"
|
||||
f"系统以目录名为准"
|
||||
)
|
||||
|
||||
def _apply_legacy_frontmatter(self, raw: str, metadata: Dict[str, Any]) -> None:
|
||||
"""YAML 解析失败时的降级路径:老的逐行 split 解析。
|
||||
|
||||
只求尽量捞回 description/title/always,不追求正确性。
|
||||
"""
|
||||
for line in raw.split("\n"):
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
if key == "title":
|
||||
metadata["title"] = value
|
||||
elif key == "description":
|
||||
metadata["description"] = value
|
||||
elif key == "always":
|
||||
metadata["always"] = _coerce_bool(value)
|
||||
|
||||
def get_summary(self) -> str:
|
||||
"""获取技能摘要"""
|
||||
title = self.metadata.get("title", self.name)
|
||||
@@ -252,6 +437,13 @@ class SkillsLoader:
|
||||
enabled=enabled,
|
||||
)
|
||||
self.skills[name] = skill
|
||||
|
||||
parse_error = skill.metadata.get("parse_error")
|
||||
if parse_error:
|
||||
logger.error(f"Skill '{name}' frontmatter 解析失败(已降级解析): {parse_error}")
|
||||
for warning in skill.metadata.get("warnings", []):
|
||||
logger.warning(f"Skill '{name}': {warning}")
|
||||
|
||||
logger.debug(f"Loaded {source} skill: {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load {source} skill {skill_file.parent}: {e}")
|
||||
@@ -379,6 +571,12 @@ class SkillsLoader:
|
||||
"description": skill.metadata.get("description", ""),
|
||||
"auto_load": skill.metadata.get("always", False),
|
||||
"requirements": list(skill.metadata.get("requires", {}).get("bins", [])),
|
||||
"version": skill.metadata.get("version", ""),
|
||||
"license": skill.metadata.get("license", ""),
|
||||
"allowed_tools": list(skill.metadata.get("allowed_tools", [])),
|
||||
# 诊断信息:frontmatter 坏了要能在 UI 上看见,而不是只躺在日志里
|
||||
"warnings": list(skill.metadata.get("warnings", [])),
|
||||
"parse_error": skill.metadata.get("parse_error", ""),
|
||||
}
|
||||
|
||||
def toggle_skill(self, name: str, enabled: bool) -> bool:
|
||||
@@ -470,9 +668,9 @@ class SkillsLoader:
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
def _strip_frontmatter(self, content: str) -> str:
|
||||
"""从 markdown 内容中移除 YAML frontmatter"""
|
||||
"""从 markdown 内容中移除 YAML frontmatter(兼容 CRLF)"""
|
||||
if content.startswith("---"):
|
||||
match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL)
|
||||
match = _FRONTMATTER_RE.match(content)
|
||||
if match:
|
||||
return content[match.end():].strip()
|
||||
return content
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""SKILL.md frontmatter 解析回归测试。
|
||||
|
||||
核心场景:frontmatter 此前由手搓的 `split(":", 1)` 行循环解析,读不了 YAML 块标量。
|
||||
`description: |` 会被解析成字面量 "|",导致技能在系统提示词里没有任何触发语——
|
||||
它显示为「已启用」,但模型永远不会激活它,且全程不报错。
|
||||
|
||||
除此之外还覆盖:allowed-tools / version / license 不再被丢弃、CRLF 兼容、
|
||||
解析失败的降级路径、以及写入侧(build_frontmatter)与读取侧的往返一致性。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from backend.modules.agent.skills import ( # noqa: E402
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
Skill,
|
||||
SkillsLoader,
|
||||
build_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def make_skill(content: str, name: str = "demo") -> Skill:
|
||||
return Skill(name=name, path=Path(f"skills/{name}/SKILL.md"), content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主回归:块标量 description
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BLOCK_SCALAR_SKILL = """\
|
||||
---
|
||||
name: humanizer
|
||||
version: 2.1.1
|
||||
description: |
|
||||
Remove signs of AI-generated writing from text. Use when editing or reviewing
|
||||
text to make it sound more natural and human-written.
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
---
|
||||
|
||||
# Humanizer
|
||||
"""
|
||||
|
||||
|
||||
def test_block_scalar_description_is_parsed_not_truncated_to_pipe():
|
||||
"""回归:旧解析器在这里返回字面量 "|",技能因此永远不会被触发。"""
|
||||
skill = make_skill(BLOCK_SCALAR_SKILL, name="humanizer-1.0.0")
|
||||
|
||||
desc = skill.metadata["description"]
|
||||
|
||||
assert desc != "|"
|
||||
assert desc.startswith("Remove signs of AI-generated writing")
|
||||
# 触发语必须完整保留——它们才是模型判断何时用这个技能的依据
|
||||
assert "Use when editing or reviewing" in desc
|
||||
assert "human-written" in desc
|
||||
|
||||
|
||||
def test_block_scalar_skill_reaches_the_model_prompt(tmp_path):
|
||||
"""端到端:坏掉的 description 最终会进系统提示词,这里守住那条链路。"""
|
||||
skill_dir = tmp_path / "skills" / "humanizer-1.0.0"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(BLOCK_SCALAR_SKILL, encoding="utf-8")
|
||||
|
||||
loader = SkillsLoader(
|
||||
skills_dir=tmp_path / "skills",
|
||||
builtin_skills_dir=tmp_path / "no-builtin",
|
||||
external_skills_dirs=[],
|
||||
)
|
||||
summary = loader.build_skills_summary()
|
||||
|
||||
assert "humanizer-1.0.0" in summary # 目录名 = 身份,模型据此拼 read_file 路径
|
||||
assert "Use when editing or reviewing" in summary
|
||||
assert ": |" not in summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 此前被解析后丢弃的开放标准字段
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_allowed_tools_list_form_is_parsed():
|
||||
skill = make_skill(BLOCK_SCALAR_SKILL, name="humanizer-1.0.0")
|
||||
assert skill.metadata["allowed_tools"] == ["Read", "Write", "Edit"]
|
||||
|
||||
|
||||
def test_allowed_tools_inline_comma_form_is_parsed():
|
||||
"""Claude Code 生态里也有单行逗号分隔的写法,两种都要认。"""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: agent-browser\n"
|
||||
"description: Browser automation.\n"
|
||||
"allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)\n"
|
||||
"---\n\n# Browser\n"
|
||||
)
|
||||
skill = make_skill(content, name="agent-browser")
|
||||
assert skill.metadata["allowed_tools"] == [
|
||||
"Bash(npx agent-browser:*)",
|
||||
"Bash(agent-browser:*)",
|
||||
]
|
||||
|
||||
|
||||
def test_version_and_license_are_parsed():
|
||||
content = (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: d\n"
|
||||
"version: 2.1.1\n"
|
||||
"license: MIT\n"
|
||||
"---\n\nbody\n"
|
||||
)
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["version"] == "2.1.1"
|
||||
assert skill.metadata["license"] == "MIT"
|
||||
|
||||
|
||||
def test_version_is_not_coerced_to_float():
|
||||
"""`version: 1.0` 在 YAML 里是 float,不能变成 '1.0' 以外的东西。"""
|
||||
content = "---\nname: demo\ndescription: d\nversion: 1.0\n---\n\nbody\n"
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["version"] == "1.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 身份:目录名为准,frontmatter name 不一致时告警
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_directory_name_remains_the_identity_and_mismatch_warns():
|
||||
""".skills_config.json / API 路由 / read_file 拦截都以目录名为键,不能改身份。"""
|
||||
skill = make_skill(BLOCK_SCALAR_SKILL, name="humanizer-1.0.0")
|
||||
|
||||
assert skill.name == "humanizer-1.0.0"
|
||||
assert skill.metadata["name"] == "humanizer"
|
||||
assert any("不一致" in w for w in skill.metadata["warnings"])
|
||||
|
||||
|
||||
def test_matching_name_produces_no_mismatch_warning():
|
||||
content = "---\nname: demo\ndescription: d\n---\n\nbody\n"
|
||||
skill = make_skill(content, name="demo")
|
||||
assert not any("不一致" in w for w in skill.metadata["warnings"])
|
||||
|
||||
|
||||
def test_non_kebab_case_name_warns():
|
||||
content = "---\nname: Demo_Skill\ndescription: d\n---\n\nbody\n"
|
||||
skill = make_skill(content, name="Demo_Skill")
|
||||
assert any("kebab-case" in w for w in skill.metadata["warnings"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# always / requires 的多种写法
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("always: true", True),
|
||||
("always: false", False),
|
||||
('always: "yes"', True), # 手写字符串
|
||||
("always: 1", True),
|
||||
("always: no", False),
|
||||
],
|
||||
)
|
||||
def test_always_accepts_bool_and_string_forms(raw, expected):
|
||||
content = f"---\nname: demo\ndescription: d\n{raw}\n---\n\nbody\n"
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["always"] is expected
|
||||
assert skill.auto_load is expected
|
||||
|
||||
|
||||
def test_always_defaults_to_false_when_absent():
|
||||
skill = make_skill("---\nname: demo\ndescription: d\n---\n\nbody\n")
|
||||
assert skill.metadata["always"] is False
|
||||
|
||||
|
||||
def test_legacy_metadata_json_string_still_works():
|
||||
"""老写法:metadata 是一行 JSON 字符串。存量技能靠它,不能破坏。"""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: d\n"
|
||||
'metadata: \'{"CountBot": {"always": true, "requires": {"bins": ["node"]}}}\'\n'
|
||||
"---\n\nbody\n"
|
||||
)
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["always"] is True
|
||||
assert skill.metadata["requires"] == {"bins": ["node"]}
|
||||
|
||||
|
||||
def test_metadata_as_yaml_flow_mapping_works():
|
||||
"""API 写出来的 `metadata: {"CountBot": {...}}` 恰好是合法的 YAML flow map。"""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: d\n"
|
||||
'metadata: {"CountBot": {"always": true, "requires": {"bins": ["git"]}}}\n'
|
||||
"---\n\nbody\n"
|
||||
)
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["always"] is True
|
||||
assert skill.metadata["requires"] == {"bins": ["git"]}
|
||||
|
||||
|
||||
def test_top_level_requires_is_supported_and_wins_over_nested():
|
||||
content = (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: d\n"
|
||||
"requires:\n"
|
||||
" bins: [node]\n"
|
||||
" env: [TOKEN]\n"
|
||||
'metadata: {"CountBot": {"requires": {"bins": ["ignored"]}}}\n'
|
||||
"---\n\nbody\n"
|
||||
)
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["requires"] == {"bins": ["node"], "env": ["TOKEN"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健壮性:坏输入不能让技能静默消失
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_malformed_yaml_sets_parse_error_and_degrades_gracefully():
|
||||
"""YAML 崩了也要捞回 description,并把错误显式暴露出去。"""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: demo\n"
|
||||
"description: still readable\n"
|
||||
"bad: [unclosed\n"
|
||||
"---\n\nbody\n"
|
||||
)
|
||||
skill = make_skill(content)
|
||||
|
||||
assert skill.metadata["parse_error"] # 不再静默
|
||||
assert skill.metadata["description"] == "still readable" # 降级路径捞回来了
|
||||
|
||||
|
||||
def test_frontmatter_that_is_not_a_mapping_sets_parse_error():
|
||||
content = "---\n- just\n- a\n- list\n---\n\nbody\n"
|
||||
skill = make_skill(content)
|
||||
assert "映射" in skill.metadata["parse_error"]
|
||||
|
||||
|
||||
def test_missing_description_warns():
|
||||
skill = make_skill("---\nname: demo\n---\n\nbody\n")
|
||||
assert any("description" in w for w in skill.metadata["warnings"])
|
||||
|
||||
|
||||
def test_overlong_description_warns_but_still_loads():
|
||||
long_desc = "x" * (MAX_DESCRIPTION_LENGTH + 1)
|
||||
content = f"---\nname: demo\ndescription: {long_desc}\n---\n\nbody\n"
|
||||
skill = make_skill(content)
|
||||
|
||||
assert len(skill.metadata["description"]) == MAX_DESCRIPTION_LENGTH + 1 # 不截断
|
||||
assert any("超过开放标准上限" in w for w in skill.metadata["warnings"])
|
||||
|
||||
|
||||
def test_no_frontmatter_yields_defaults():
|
||||
skill = make_skill("# Just a heading\n\nbody\n")
|
||||
assert skill.metadata["description"] == ""
|
||||
assert skill.metadata["title"] == "demo"
|
||||
assert skill.metadata["parse_error"] == ""
|
||||
|
||||
|
||||
def test_empty_frontmatter_yields_defaults():
|
||||
skill = make_skill("---\n\n---\n\nbody\n")
|
||||
assert skill.metadata["description"] == ""
|
||||
assert skill.metadata["parse_error"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRLF:Windows 上编辑过的 SKILL.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_crlf_frontmatter_is_parsed():
|
||||
"""旧正则写死了 `^---\\n`,CRLF 文件会整段匹配不上 → 元数据全空。"""
|
||||
content = "---\r\nname: demo\r\ndescription: windows line endings\r\n---\r\n\r\n# Body\r\n"
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["description"] == "windows line endings"
|
||||
|
||||
|
||||
def test_crlf_frontmatter_is_stripped_from_context_body(tmp_path):
|
||||
"""always 技能会把全文注入提示词——frontmatter 必须被剥掉,否则 YAML 泄进 prompt。"""
|
||||
skill_dir = tmp_path / "skills" / "demo"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\r\nname: demo\r\ndescription: d\r\n---\r\n\r\n# Real Body\r\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loader = SkillsLoader(
|
||||
skills_dir=tmp_path / "skills",
|
||||
builtin_skills_dir=tmp_path / "no-builtin",
|
||||
external_skills_dirs=[],
|
||||
)
|
||||
body = loader.load_skills_for_context(["demo"])
|
||||
|
||||
assert "# Real Body" in body
|
||||
assert "description:" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 写入侧:build_frontmatter 必须能被读取侧原样读回
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_frontmatter_round_trips():
|
||||
content = build_frontmatter(
|
||||
name="demo",
|
||||
description="A demo skill.",
|
||||
auto_load=True,
|
||||
requirements=["node", "git"],
|
||||
) + "# Body\n"
|
||||
|
||||
skill = make_skill(content)
|
||||
assert skill.metadata["description"] == "A demo skill."
|
||||
assert skill.metadata["always"] is True
|
||||
assert skill.metadata["requires"] == {"bins": ["node", "git"]}
|
||||
assert skill.metadata["parse_error"] == ""
|
||||
|
||||
|
||||
def test_build_frontmatter_survives_colon_in_description():
|
||||
"""裸 f-string 拼接会在这里产出非法 YAML,让整个 frontmatter 解析失败。"""
|
||||
tricky = "Usage: run the tool. Note: it needs auth."
|
||||
skill = make_skill(build_frontmatter("demo", tricky) + "# Body\n")
|
||||
|
||||
assert skill.metadata["parse_error"] == ""
|
||||
assert skill.metadata["description"] == tricky
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tricky",
|
||||
[
|
||||
"# starts with a hash",
|
||||
"- starts with a dash",
|
||||
"multi\nline\ndescription",
|
||||
"中文描述:包含全角冒号与英文 colon: here",
|
||||
'has "double quotes" and \'single quotes\'',
|
||||
"{braces: like_yaml_flow}",
|
||||
],
|
||||
)
|
||||
def test_build_frontmatter_survives_yaml_hostile_descriptions(tricky):
|
||||
skill = make_skill(build_frontmatter("demo", tricky) + "# Body\n")
|
||||
|
||||
assert skill.metadata["parse_error"] == ""
|
||||
assert skill.metadata["description"] == tricky.strip()
|
||||
|
||||
|
||||
def test_build_frontmatter_keeps_chinese_readable():
|
||||
"""allow_unicode=True:中文不能被转义成 \\uXXXX,否则用户在编辑器里没法读。"""
|
||||
content = build_frontmatter("demo", "多智能体团队管理")
|
||||
assert "多智能体团队管理" in content
|
||||
Reference in New Issue
Block a user