467 lines
17 KiB
Python
467 lines
17 KiB
Python
"""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
|