fischer-agentkit/tests/unit/test_skill_md.py

475 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 == 0
# 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_zero(self):
config = SkillConfig(
name="test",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
)
assert config.disclosure_level == 0
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 == 0
# ── 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)
# 无效文件应被跳过(纯文本无 frontmattername 为空)
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"