475 lines
17 KiB
Python
475 lines
17 KiB
Python
"""SKILL.md 解析器单元测试"""
|
||
|
||
import os
|
||
import tempfile
|
||
|
||
import pytest
|
||
|
||
from agentkit.skills.base import Skill, SkillConfig
|
||
from agentkit.skills.loader import SkillLoader
|
||
from agentkit.skills.registry import SkillRegistry
|
||
from agentkit.skills.skill_md import SkillMdParser
|
||
|
||
|
||
# ── 测试用 SKILL.md 内容 ──────────────────────────────────
|
||
|
||
FULL_SKILL_MD = '''\
|
||
---
|
||
name: content-generator
|
||
description: "Generate high-quality content based on requirements"
|
||
agent_type: content_generation
|
||
execution_mode: react
|
||
intent:
|
||
keywords: ["generate", "write", "content"]
|
||
description: "Content generation tasks"
|
||
examples: ["Write a blog post", "Generate marketing copy"]
|
||
quality_gate:
|
||
required_fields: ["content"]
|
||
min_word_count: 100
|
||
max_retries: 3
|
||
custom_validator: "validators.check_quality"
|
||
---
|
||
|
||
# Trigger
|
||
- User asks to generate content
|
||
- Keywords: generate, write, create content
|
||
|
||
# Steps
|
||
1. Analyze the user's requirements and target audience
|
||
2. Research relevant topics and gather information
|
||
3. Draft the content following best practices
|
||
4. Review and refine the output
|
||
|
||
# Pitfalls
|
||
- Don't generate overly generic content
|
||
- Avoid plagiarism by always creating original content
|
||
- Don't ignore the target audience's preferences
|
||
|
||
# Verification
|
||
- Content meets minimum word count
|
||
- Content is relevant to the user's request
|
||
- Output format matches expectations
|
||
'''
|
||
|
||
MINIMAL_SKILL_MD = '''\
|
||
---
|
||
name: minimal-skill
|
||
description: "A minimal skill"
|
||
agent_type: minimal
|
||
---
|
||
|
||
# Steps
|
||
1. Do something
|
||
'''
|
||
|
||
NO_FRONTMATTER_MD = '''\
|
||
# Steps
|
||
1. Step one
|
||
2. Step two
|
||
'''
|
||
|
||
EMPTY_FRONTMATTER_MD = '''\
|
||
---
|
||
---
|
||
|
||
# Steps
|
||
1. Step one
|
||
'''
|
||
|
||
|
||
def _write_skill_md(directory: str, filename: str, content: str) -> str:
|
||
path = os.path.join(directory, filename)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
return path
|
||
|
||
|
||
# ── SkillMdParser.parse 测试 ──────────────────────────────
|
||
|
||
|
||
class TestSkillMdParserParse:
|
||
"""SkillMdParser.parse() 解析测试"""
|
||
|
||
def test_parse_full_skill_md(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
|
||
assert frontmatter["name"] == "content-generator"
|
||
assert frontmatter["description"] == "Generate high-quality content based on requirements"
|
||
assert frontmatter["agent_type"] == "content_generation"
|
||
assert frontmatter["execution_mode"] == "react"
|
||
assert frontmatter["intent"]["keywords"] == ["generate", "write", "content"]
|
||
assert frontmatter["quality_gate"]["required_fields"] == ["content"]
|
||
assert frontmatter["quality_gate"]["min_word_count"] == 100
|
||
|
||
assert "trigger" in sections
|
||
assert "steps" in sections
|
||
assert "pitfalls" in sections
|
||
assert "verification" in sections
|
||
assert "Analyze the user's requirements" in sections["steps"]
|
||
assert "Don't generate overly generic content" in sections["pitfalls"]
|
||
|
||
assert "Trigger" not in body or "# Trigger" in body
|
||
|
||
def test_parse_minimal_skill_md(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "minimal.md", MINIMAL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
|
||
assert frontmatter["name"] == "minimal-skill"
|
||
assert frontmatter["description"] == "A minimal skill"
|
||
assert "steps" in sections
|
||
assert "Do something" in sections["steps"]
|
||
|
||
def test_parse_no_frontmatter(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "no_fm.md", NO_FRONTMATTER_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
|
||
assert frontmatter == {}
|
||
assert "steps" in sections
|
||
|
||
def test_parse_empty_frontmatter(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "empty_fm.md", EMPTY_FRONTMATTER_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
|
||
assert frontmatter == {}
|
||
assert "steps" in sections
|
||
|
||
def test_parse_missing_sections_graceful(self):
|
||
content = """\
|
||
---
|
||
name: no-sections
|
||
description: "No body sections"
|
||
agent_type: test
|
||
---
|
||
"""
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "nosec.md", content)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
|
||
assert frontmatter["name"] == "no-sections"
|
||
assert sections == {}
|
||
|
||
|
||
# ── SkillMdParser.to_skill_config 测试 ────────────────────
|
||
|
||
|
||
class TestSkillMdToSkillConfig:
|
||
"""SkillMdParser.to_skill_config() 转换测试"""
|
||
|
||
def test_to_skill_config_full(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "full.md", FULL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
config = SkillMdParser.to_skill_config(frontmatter, sections, path)
|
||
|
||
assert config.name == "content-generator"
|
||
assert config.agent_type == "content_generation"
|
||
assert config.description == "Generate high-quality content based on requirements"
|
||
assert config.execution_mode == "react"
|
||
assert config.intent.keywords == ["generate", "write", "content"]
|
||
assert config.intent.description == "Content generation tasks"
|
||
assert config.intent.examples == ["Write a blog post", "Generate marketing copy"]
|
||
assert config.quality_gate.required_fields == ["content"]
|
||
assert config.quality_gate.min_word_count == 100
|
||
assert config.quality_gate.max_retries == 3
|
||
assert config.quality_gate.custom_validator == "validators.check_quality"
|
||
assert config.prompt is not None
|
||
assert "instructions" in config.prompt
|
||
assert "constraints" in config.prompt
|
||
assert "output_format" in config.prompt
|
||
assert "context" in config.prompt
|
||
|
||
def test_to_skill_config_level_0_summary_only(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "level0.md", FULL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
config = SkillMdParser.to_skill_config(
|
||
frontmatter, sections, path, disclosure_level=0,
|
||
)
|
||
|
||
assert config.name == "content-generator"
|
||
assert config.description != ""
|
||
assert config.disclosure_level == 1
|
||
# Level 0: prompt 仅含 identity(概要信息)
|
||
assert config.prompt is not None
|
||
assert "identity" in config.prompt
|
||
assert "instructions" not in config.prompt
|
||
|
||
def test_to_skill_config_level_1_full(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "level1.md", FULL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
config = SkillMdParser.to_skill_config(
|
||
frontmatter, sections, path, disclosure_level=1,
|
||
)
|
||
|
||
assert config.name == "content-generator"
|
||
assert config.disclosure_level == 1
|
||
assert config.prompt is not None
|
||
assert "instructions" in config.prompt
|
||
|
||
def test_to_skill_config_minimal(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "minimal.md", MINIMAL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
config = SkillMdParser.to_skill_config(frontmatter, sections, path)
|
||
|
||
assert config.name == "minimal-skill"
|
||
assert config.agent_type == "minimal"
|
||
assert config.execution_mode == "react" # 默认值
|
||
assert config.intent.keywords == []
|
||
assert config.quality_gate.required_fields == []
|
||
|
||
def test_to_skill_config_no_frontmatter(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "no_fm.md", NO_FRONTMATTER_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
# 无 frontmatter 时 name 为空,无法创建有效的 SkillConfig
|
||
# 验证解析结果正确即可
|
||
assert frontmatter == {}
|
||
assert "steps" in sections
|
||
|
||
def test_skill_md_path_stored(self):
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "path_test.md", FULL_SKILL_MD)
|
||
frontmatter, sections, body = SkillMdParser.parse(path)
|
||
config = SkillMdParser.to_skill_config(frontmatter, sections, path)
|
||
|
||
assert config.skill_md_path == path
|
||
|
||
|
||
# ── SkillConfig 新字段测试 ─────────────────────────────────
|
||
|
||
|
||
class TestSkillConfigNewFields:
|
||
"""SkillConfig 新增 skill_md_path 和 disclosure_level 字段测试"""
|
||
|
||
def test_default_skill_md_path_is_none(self):
|
||
config = SkillConfig(
|
||
name="test",
|
||
agent_type="test",
|
||
task_mode="llm_generate",
|
||
prompt={"identity": "test"},
|
||
)
|
||
assert config.skill_md_path is None
|
||
|
||
def test_default_disclosure_level_is_one(self):
|
||
config = SkillConfig(
|
||
name="test",
|
||
agent_type="test",
|
||
task_mode="llm_generate",
|
||
prompt={"identity": "test"},
|
||
)
|
||
assert config.disclosure_level == 1
|
||
|
||
def test_skill_md_path_set(self):
|
||
config = SkillConfig(
|
||
name="test",
|
||
agent_type="test",
|
||
task_mode="llm_generate",
|
||
prompt={"identity": "test"},
|
||
skill_md_path="/path/to/skill.md",
|
||
)
|
||
assert config.skill_md_path == "/path/to/skill.md"
|
||
|
||
def test_disclosure_level_set(self):
|
||
config = SkillConfig(
|
||
name="test",
|
||
agent_type="test",
|
||
task_mode="llm_generate",
|
||
prompt={"identity": "test"},
|
||
disclosure_level=2,
|
||
)
|
||
assert config.disclosure_level == 2
|
||
|
||
def test_to_dict_includes_new_fields(self):
|
||
config = SkillConfig(
|
||
name="test",
|
||
agent_type="test",
|
||
task_mode="llm_generate",
|
||
prompt={"identity": "test"},
|
||
skill_md_path="/path/to/skill.md",
|
||
disclosure_level=1,
|
||
)
|
||
d = config.to_dict()
|
||
assert d["skill_md_path"] == "/path/to/skill.md"
|
||
assert d["disclosure_level"] == 1
|
||
|
||
def test_from_dict_includes_new_fields(self):
|
||
data = {
|
||
"name": "test",
|
||
"agent_type": "test",
|
||
"task_mode": "llm_generate",
|
||
"prompt": {"identity": "test"},
|
||
"skill_md_path": "/path/to/skill.md",
|
||
"disclosure_level": 2,
|
||
}
|
||
config = SkillConfig.from_dict(data)
|
||
assert config.skill_md_path == "/path/to/skill.md"
|
||
assert config.disclosure_level == 2
|
||
|
||
def test_from_dict_defaults_new_fields(self):
|
||
data = {
|
||
"name": "test",
|
||
"agent_type": "test",
|
||
"task_mode": "llm_generate",
|
||
"prompt": {"identity": "test"},
|
||
}
|
||
config = SkillConfig.from_dict(data)
|
||
assert config.skill_md_path is None
|
||
assert config.disclosure_level == 1
|
||
|
||
|
||
# ── SkillLoader.load_from_skill_md 测试 ───────────────────
|
||
|
||
|
||
class TestSkillLoaderFromSkillMd:
|
||
"""SkillLoader.load_from_skill_md() 加载测试"""
|
||
|
||
def test_load_from_skill_md_creates_skill(self):
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD)
|
||
skill = loader.load_from_skill_md(path)
|
||
|
||
assert isinstance(skill, Skill)
|
||
assert skill.name == "content-generator"
|
||
assert skill.config.agent_type == "content_generation"
|
||
assert skill.config.skill_md_path == path
|
||
assert skill.config.disclosure_level == 1 # 默认 level=1
|
||
|
||
def test_load_from_skill_md_registers_in_registry(self):
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD)
|
||
loader.load_from_skill_md(path)
|
||
|
||
assert registry.has_skill("content-generator")
|
||
|
||
def test_load_from_skill_md_level_0(self):
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD)
|
||
skill = loader.load_from_skill_md(path, disclosure_level=0)
|
||
|
||
assert skill.config.disclosure_level == 0
|
||
# Level 0: prompt 仅含 identity,不含 instructions
|
||
assert skill.config.prompt is not None
|
||
assert "identity" in skill.config.prompt
|
||
assert "instructions" not in skill.config.prompt
|
||
|
||
def test_load_from_skill_md_level_1(self):
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD)
|
||
skill = loader.load_from_skill_md(path, disclosure_level=1)
|
||
|
||
assert skill.config.disclosure_level == 1
|
||
assert skill.config.prompt is not None
|
||
assert "instructions" in skill.config.prompt
|
||
|
||
def test_load_from_directory_includes_md_files(self):
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
_write_skill_md(tmpdir, "skill.md", FULL_SKILL_MD)
|
||
skills = loader.load_from_directory(tmpdir)
|
||
|
||
assert len(skills) == 1
|
||
assert skills[0].name == "content-generator"
|
||
|
||
def test_load_from_directory_mixed_yaml_and_md(self):
|
||
import yaml
|
||
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
# YAML 文件
|
||
yaml_path = os.path.join(tmpdir, "yaml_skill.yaml")
|
||
with open(yaml_path, "w", encoding="utf-8") as f:
|
||
yaml.dump({
|
||
"name": "yaml_skill",
|
||
"agent_type": "yaml",
|
||
"task_mode": "llm_generate",
|
||
"prompt": {"identity": "YAML 技能"},
|
||
}, f)
|
||
|
||
# SKILL.md 文件
|
||
_write_skill_md(tmpdir, "md_skill.md", FULL_SKILL_MD)
|
||
|
||
skills = loader.load_from_directory(tmpdir)
|
||
assert len(skills) == 2
|
||
names = [s.name for s in skills]
|
||
assert "yaml_skill" in names
|
||
assert "content-generator" in names
|
||
|
||
def test_load_from_directory_skips_invalid_md(self, caplog):
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
# 无效的 MD(不是合法的 SKILL.md 格式,YAML 解析后缺少必要字段)
|
||
invalid_md = "This is just plain text, not a valid SKILL.md at all."
|
||
_write_skill_md(tmpdir, "invalid.md", invalid_md)
|
||
|
||
with caplog.at_level("WARNING"):
|
||
skills = loader.load_from_directory(tmpdir)
|
||
|
||
# 无效文件应被跳过(纯文本无 frontmatter,name 为空)
|
||
assert len(skills) == 0
|
||
|
||
|
||
# ── CLI skill create 测试 ─────────────────────────────────
|
||
|
||
|
||
class TestCliSkillCreate:
|
||
"""CLI skill create 命令测试"""
|
||
|
||
def test_create_generates_valid_skill_md(self):
|
||
from typer.testing import CliRunner
|
||
from agentkit.cli.skill import skill_app
|
||
|
||
runner = CliRunner()
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
result = runner.invoke(skill_app, ["create", "my-skill", "--output-dir", tmpdir])
|
||
assert result.exit_code == 0
|
||
|
||
output_path = os.path.join(tmpdir, "my-skill.md")
|
||
assert os.path.exists(output_path)
|
||
|
||
# 验证生成的文件可以被解析
|
||
frontmatter, sections, body = SkillMdParser.parse(output_path)
|
||
assert frontmatter["name"] == "my-skill"
|
||
assert "steps" in sections
|
||
assert "pitfalls" in sections
|
||
assert "verification" in sections
|
||
|
||
def test_create_template_is_loadable(self):
|
||
from typer.testing import CliRunner
|
||
from agentkit.cli.skill import skill_app
|
||
|
||
runner = CliRunner()
|
||
with tempfile.TemporaryDirectory() as tmpdir:
|
||
runner.invoke(skill_app, ["create", "loadable-skill", "--output-dir", tmpdir])
|
||
|
||
output_path = os.path.join(tmpdir, "loadable-skill.md")
|
||
registry = SkillRegistry()
|
||
loader = SkillLoader(skill_registry=registry)
|
||
skill = loader.load_from_skill_md(output_path)
|
||
|
||
assert skill.name == "loadable-skill"
|