Merge pull request #94 from hhzscreate/main

skill解析能力适配:skill加载改为yaml解析器,写入使用safe_dump
This commit is contained in:
countbot-ai
2026-07-15 21:07:12 +08:00
committed by GitHub
3 changed files with 602 additions and 72 deletions
+13 -35
View File
@@ -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
View File
@@ -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 分隔符。兼容 CRLFWindows 上编辑过的 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
+354
View File
@@ -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"] == ""
# ---------------------------------------------------------------------------
# CRLFWindows 上编辑过的 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