fischer-agentkit/tests/unit/test_skill_config.py

467 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.

"""SkillConfig 单元测试"""
import os
import tempfile
import pytest
import yaml
from agentkit.core.exceptions import ConfigValidationError
from agentkit.skills.base import IntentConfig, QualityGateConfig, SkillConfig, Skill
# ── IntentConfig 测试 ──────────────────────────────────────
class TestIntentConfig:
"""IntentConfig 数据类测试"""
def test_default_values(self):
intent = IntentConfig()
assert intent.keywords == []
assert intent.description == ""
assert intent.examples == []
def test_from_dict_with_all_fields(self):
data = {
"keywords": ["生成", "写作"],
"description": "内容生成意图",
"examples": ["帮我写一篇文章", "生成一段文案"],
}
intent = IntentConfig(**data)
assert intent.keywords == ["生成", "写作"]
assert intent.description == "内容生成意图"
assert intent.examples == ["帮我写一篇文章", "生成一段文案"]
def test_empty_keywords_is_valid(self):
intent = IntentConfig(keywords=[])
assert intent.keywords == []
# ── QualityGateConfig 测试 ─────────────────────────────────
class TestQualityGateConfig:
"""QualityGateConfig 数据类测试"""
def test_default_values(self):
gate = QualityGateConfig()
assert gate.required_fields == []
assert gate.min_word_count == 0
assert gate.max_retries == 0
assert gate.custom_validator is None
def test_from_dict_with_all_fields(self):
data = {
"required_fields": ["title", "body"],
"min_word_count": 100,
"max_retries": 3,
"custom_validator": "validators.check_quality",
}
gate = QualityGateConfig(**data)
assert gate.required_fields == ["title", "body"]
assert gate.min_word_count == 100
assert gate.max_retries == 3
assert gate.custom_validator == "validators.check_quality"
def test_max_retries_defaults_to_zero(self):
gate = QualityGateConfig()
assert gate.max_retries == 0
# ── SkillConfig 测试 ───────────────────────────────────────
class TestSkillConfig:
"""SkillConfig 继承 AgentConfig 并扩展 v2 字段"""
def test_from_dict_with_intent_and_quality_gate(self):
data = {
"name": "content_gen",
"agent_type": "content_generation",
"task_mode": "llm_generate",
"prompt": {"identity": "你是内容生成助手"},
"intent": {
"keywords": ["生成", "写作"],
"description": "内容生成意图",
"examples": ["帮我写文章"],
},
"quality_gate": {
"required_fields": ["title", "body"],
"min_word_count": 100,
"max_retries": 3,
},
"execution_mode": "react",
"max_steps": 10,
}
config = SkillConfig.from_dict(data)
assert config.name == "content_gen"
assert config.intent.keywords == ["生成", "写作"]
assert config.intent.description == "内容生成意图"
assert config.quality_gate.required_fields == ["title", "body"]
assert config.quality_gate.max_retries == 3
assert config.execution_mode == "react"
assert config.max_steps == 10
def test_from_old_agent_config_dict_auto_fills_defaults(self):
"""旧 AgentConfig 字典(无 intent/quality_gate应自动填充默认值"""
data = {
"name": "geo_writer",
"agent_type": "geo_writing",
"task_mode": "llm_generate",
"prompt": {"identity": "你是 GEO 写作助手"},
}
config = SkillConfig.from_dict(data)
assert config.name == "geo_writer"
assert isinstance(config.intent, IntentConfig)
assert config.intent.keywords == []
assert config.intent.description == ""
assert config.intent.examples == []
assert isinstance(config.quality_gate, QualityGateConfig)
assert config.quality_gate.required_fields == []
assert config.quality_gate.max_retries == 0
def test_execution_mode_defaults_to_react(self):
data = {
"name": "test_skill",
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "test"},
}
config = SkillConfig.from_dict(data)
assert config.execution_mode == "react"
def test_max_steps_defaults_to_five(self):
data = {
"name": "test_skill",
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "test"},
}
config = SkillConfig.from_dict(data)
assert config.max_steps == 5
def test_backward_compat_old_yaml_without_intent(self):
"""旧 YAML 无 intent 字段 → intent 默认为空 IntentConfig"""
yaml_content = yaml.dump({
"name": "legacy_skill",
"agent_type": "legacy",
"task_mode": "llm_generate",
"prompt": {"identity": "旧技能"},
})
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, encoding="utf-8"
) as f:
f.write(yaml_content)
path = f.name
try:
config = SkillConfig.from_yaml(path)
assert config.name == "legacy_skill"
assert isinstance(config.intent, IntentConfig)
assert config.intent.keywords == []
assert isinstance(config.quality_gate, QualityGateConfig)
assert config.quality_gate.max_retries == 0
assert config.execution_mode == "react"
finally:
os.unlink(path)
def test_from_yaml_loads_correctly(self):
yaml_content = yaml.dump({
"name": "yaml_skill",
"agent_type": "yaml_type",
"task_mode": "llm_generate",
"prompt": {"identity": "YAML 技能"},
"intent": {"keywords": ["yaml"], "description": "YAML 加载测试"},
"quality_gate": {"required_fields": ["result"], "max_retries": 2},
"execution_mode": "direct",
"max_steps": 3,
})
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, encoding="utf-8"
) as f:
f.write(yaml_content)
path = f.name
try:
config = SkillConfig.from_yaml(path)
assert config.name == "yaml_skill"
assert config.intent.keywords == ["yaml"]
assert config.quality_gate.max_retries == 2
assert config.execution_mode == "direct"
assert config.max_steps == 3
finally:
os.unlink(path)
def test_to_dict_includes_v2_fields(self):
data = {
"name": "dict_skill",
"agent_type": "dict_type",
"task_mode": "llm_generate",
"prompt": {"identity": "字典技能"},
"intent": {"keywords": ["dict"]},
"quality_gate": {"required_fields": ["output"]},
"execution_mode": "custom",
"max_steps": 7,
}
config = SkillConfig.from_dict(data)
result = config.to_dict()
assert "intent" in result
assert result["intent"]["keywords"] == ["dict"]
assert "quality_gate" in result
assert result["quality_gate"]["required_fields"] == ["output"]
assert result["execution_mode"] == "custom"
assert result["max_steps"] == 7
def test_to_dict_includes_v2_defaults_when_not_provided(self):
data = {
"name": "minimal_skill",
"agent_type": "minimal",
"task_mode": "llm_generate",
"prompt": {"identity": "最小技能"},
}
config = SkillConfig.from_dict(data)
result = config.to_dict()
assert "intent" in result
assert result["intent"]["keywords"] == []
assert "quality_gate" in result
assert result["quality_gate"]["max_retries"] == 0
assert result["execution_mode"] == "react"
assert result["max_steps"] == 5
def test_fallback_strategies_default_is_none(self):
"""SkillConfig.fallback_strategies defaults to None (#6)."""
config = SkillConfig(
name="test",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
)
assert config.fallback_strategies is None
def test_fallback_strategies_from_dict(self):
"""from_dict populates fallback_strategies (#6)."""
data = {
"name": "test",
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "test"},
"fallback_strategies": ["simplified_rewoo", "react", "direct"],
}
config = SkillConfig.from_dict(data)
assert config.fallback_strategies == ["simplified_rewoo", "react", "direct"]
def test_fallback_strategies_to_dict(self):
"""to_dict includes fallback_strategies when set (#6, #14)."""
config = SkillConfig(
name="test",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
fallback_strategies=["simplified_rewoo", "direct"],
)
result = config.to_dict()
assert result["fallback_strategies"] == ["simplified_rewoo", "direct"]
def test_fallback_strategies_to_dict_none_when_not_set(self):
"""to_dict includes fallback_strategies as None when not configured (#14)."""
config = SkillConfig(
name="test",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
)
result = config.to_dict()
assert "fallback_strategies" in result
assert result["fallback_strategies"] is None
def test_fallback_strategies_yaml_round_trip(self):
"""YAML round-trip preserves fallback_strategies (#6)."""
import tempfile
data = {
"name": "round_trip",
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "test"},
"fallback_strategies": ["simplified_rewoo", "react", "direct"],
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
yaml.dump(data, f)
path = f.name
try:
config = SkillConfig.from_yaml(path)
assert config.fallback_strategies == ["simplified_rewoo", "react", "direct"]
# Round-trip through to_dict -> from_dict
round_tripped = SkillConfig.from_dict(config.to_dict())
assert round_tripped.fallback_strategies == ["simplified_rewoo", "react", "direct"]
finally:
os.unlink(path)
def test_fallback_strategies_invalid_value_raises_error(self):
"""Invalid fallback_strategies value raises ConfigValidationError (#20)."""
# _validate_v2 is called in __init__, so construction itself raises
with pytest.raises(ConfigValidationError, match="fallback_strategies"):
SkillConfig(
name="test_invalid_fallback",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
fallback_strategies=["simplified_rewoo", "invalid_strategy"],
)
def test_fallback_strategies_invalid_value_via_from_dict(self):
"""Invalid fallback_strategies via from_dict also raises (#20)."""
with pytest.raises(ConfigValidationError, match="fallback_strategies"):
SkillConfig.from_dict({
"name": "test_invalid_fallback",
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "test"},
"fallback_strategies": ["bogus"],
})
def test_fallback_strategies_valid_subset_accepted(self):
"""Valid subset of ReWOOEngine.VALID_STRATEGIES is accepted (#20)."""
# All valid strategies
for strategies in [
["simplified_rewoo"],
["react", "direct"],
["simplified_rewoo", "react", "direct", "plan_exec"],
]:
config = SkillConfig(
name="test",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
fallback_strategies=strategies,
)
assert config.fallback_strategies == strategies
def test_fallback_strategies_none_bypasses_validation(self):
"""None fallback_strategies skips validation (uses ReWOOEngine defaults) (#20)."""
config = SkillConfig(
name="test",
agent_type="test",
task_mode="llm_generate",
prompt={"identity": "test"},
fallback_strategies=None,
)
assert config.fallback_strategies is None
def test_invalid_execution_mode_raises_config_validation_error(self):
data = {
"name": "bad_mode",
"agent_type": "bad",
"task_mode": "llm_generate",
"prompt": {"identity": "坏模式"},
"execution_mode": "invalid_mode",
}
with pytest.raises(ConfigValidationError):
SkillConfig.from_dict(data)
def test_direct_execution_mode(self):
data = {
"name": "direct_skill",
"agent_type": "direct",
"task_mode": "tool_call",
"tools": ["some_tool"],
"execution_mode": "direct",
}
config = SkillConfig.from_dict(data)
assert config.execution_mode == "direct"
def test_custom_execution_mode(self):
data = {
"name": "custom_skill",
"agent_type": "custom",
"task_mode": "custom",
"custom_handler": "handlers.custom",
"execution_mode": "custom",
}
config = SkillConfig.from_dict(data)
assert config.execution_mode == "custom"
# ── Skill 测试 ─────────────────────────────────────────────
class TestSkill:
"""Skill 类测试"""
def _make_config(self, name: str = "test_skill") -> SkillConfig:
return SkillConfig.from_dict({
"name": name,
"agent_type": "test",
"task_mode": "llm_generate",
"prompt": {"identity": "测试技能"},
})
def test_skill_name_property(self):
config = self._make_config("my_skill")
skill = Skill(config)
assert skill.name == "my_skill"
def test_skill_config_property(self):
config = self._make_config()
skill = Skill(config)
assert skill.config is config
def test_skill_tools_default_empty(self):
config = self._make_config()
skill = Skill(config)
assert skill.tools == []
def test_skill_bind_tool(self):
from agentkit.tools.base import Tool
class DummyTool(Tool):
async def execute(self, **kwargs):
return {}
config = self._make_config()
skill = Skill(config)
tool = DummyTool(name="t1", description="test tool")
skill.bind_tool(tool)
assert len(skill.tools) == 1
assert skill.tools[0].name == "t1"
def test_skill_unbind_tool(self):
from agentkit.tools.base import Tool
class DummyTool(Tool):
async def execute(self, **kwargs):
return {}
config = self._make_config()
skill = Skill(config)
tool = DummyTool(name="t1", description="test tool")
skill.bind_tool(tool)
skill.unbind_tool("t1")
assert skill.tools == []
def test_skill_unbind_nonexistent_tool_no_error(self):
config = self._make_config()
skill = Skill(config)
skill.unbind_tool("nonexistent") # 不应抛异常
assert skill.tools == []
def test_skill_to_dict(self):
config = self._make_config()
skill = Skill(config)
d = skill.to_dict()
assert "config" in d
assert d["config"]["name"] == "test_skill"
assert "tools" in d
assert d["tools"] == []
def test_skill_with_tools_in_constructor(self):
from agentkit.tools.base import Tool
class DummyTool(Tool):
async def execute(self, **kwargs):
return {}
config = self._make_config()
tool = DummyTool(name="t1", description="test tool")
skill = Skill(config, tools=[tool])
assert len(skill.tools) == 1