428 lines
16 KiB
Python
428 lines
16 KiB
Python
"""Unit tests for ExpertTeamRouter and skill routing utilities."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from agentkit.chat.skill_routing import (
|
||
ExecutionMode,
|
||
SkillRoutingResult,
|
||
build_skill_system_prompt,
|
||
)
|
||
from agentkit.experts.config import ExpertConfig, ExpertTemplate
|
||
from agentkit.experts.registry import ExpertTemplateRegistry
|
||
from agentkit.experts.router import ExpertTeamRouter
|
||
from agentkit.skills.base import Skill, SkillConfig
|
||
from agentkit.skills.registry import SkillRegistry
|
||
from agentkit.skills.skill_detail import SkillDetailTool
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_team_router_with_templates() -> ExpertTeamRouter:
|
||
"""Create an ExpertTeamRouter with sample templates."""
|
||
registry = ExpertTemplateRegistry()
|
||
for name in ("analyst", "strategist", "reviewer"):
|
||
config = ExpertConfig(
|
||
name=name,
|
||
agent_type="expert",
|
||
persona=f"Expert in {name}",
|
||
thinking_style="analytical",
|
||
bound_skills=[],
|
||
is_lead=(name == "analyst"),
|
||
task_mode="llm_generate",
|
||
prompt={"identity": f"Expert in {name}"},
|
||
)
|
||
template = ExpertTemplate(
|
||
name=name,
|
||
config=config,
|
||
description=f"Handles {name} tasks",
|
||
)
|
||
registry.register(template)
|
||
return ExpertTeamRouter(template_registry=registry)
|
||
|
||
|
||
def _make_team_router_empty() -> ExpertTeamRouter:
|
||
"""Create an ExpertTeamRouter with no templates."""
|
||
return ExpertTeamRouter(template_registry=ExpertTemplateRegistry())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: ExpertTeamRouter.can_handle()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestExpertTeamRouterCanHandle:
|
||
def test_can_handle_with_templates(self) -> None:
|
||
router = _make_team_router_with_templates()
|
||
assert router.can_handle("analyze this data") is True
|
||
|
||
def test_can_handle_no_templates(self) -> None:
|
||
router = _make_team_router_empty()
|
||
assert router.can_handle("analyze this data") is False
|
||
|
||
def test_can_handle_name_match(self) -> None:
|
||
router = _make_team_router_with_templates()
|
||
assert router.can_handle("I need a strategist for this") is True
|
||
|
||
def test_can_handle_description_match(self) -> None:
|
||
router = _make_team_router_with_templates()
|
||
assert router.can_handle("handles review tasks") is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: ExpertTeamRouter.resolve()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestExpertTeamRouterResolve:
|
||
def test_explicit_team_prefix(self) -> None:
|
||
router = _make_team_router_with_templates()
|
||
result = router.resolve("@team:analyst,strategist analyze the market")
|
||
assert result.team_mode is True
|
||
assert result.match_method == "explicit_team"
|
||
assert "analyst" in result.specified_experts
|
||
assert "strategist" in result.specified_experts
|
||
|
||
def test_no_team_without_prefix(self) -> None:
|
||
router = _make_team_router_with_templates()
|
||
result = router.resolve("simple question")
|
||
assert result.team_mode is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests: SkillRoutingResult data structure
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSkillRoutingResult:
|
||
def test_default_execution_mode(self) -> None:
|
||
result = SkillRoutingResult(
|
||
clean_content="test",
|
||
matched=False,
|
||
match_method="default_react",
|
||
match_confidence=0.8,
|
||
agent_name="default",
|
||
model="default",
|
||
execution_mode=ExecutionMode.REACT,
|
||
)
|
||
assert result.execution_mode == ExecutionMode.REACT
|
||
|
||
def test_direct_chat_mode(self) -> None:
|
||
result = SkillRoutingResult(
|
||
clean_content="hello",
|
||
matched=False,
|
||
match_method="regex_direct",
|
||
match_confidence=1.0,
|
||
agent_name="default",
|
||
model="default",
|
||
execution_mode=ExecutionMode.DIRECT_CHAT,
|
||
)
|
||
assert result.execution_mode == ExecutionMode.DIRECT_CHAT
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# U5: Progressive Skill Loading Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_skill_config(
|
||
name: str = "research",
|
||
description: str = "Research skill for gathering information",
|
||
disclosure_level: int = 1,
|
||
preconditions: list[str] | None = None,
|
||
) -> SkillConfig:
|
||
"""Create a SkillConfig for testing progressive loading."""
|
||
return SkillConfig(
|
||
name=name,
|
||
agent_type="skill",
|
||
description=description,
|
||
task_mode="llm_generate",
|
||
execution_mode="react",
|
||
prompt={
|
||
"identity": f"You are a {name} expert.",
|
||
"context": "Use this skill when research is needed.",
|
||
"instructions": "Step 1: gather data. Step 2: analyze.",
|
||
"constraints": "Do not hallucinate.",
|
||
"output_format": "Markdown report.",
|
||
},
|
||
disclosure_level=disclosure_level,
|
||
preconditions=preconditions,
|
||
)
|
||
|
||
|
||
def _make_skill_registry_with_skills(*configs: SkillConfig) -> SkillRegistry:
|
||
"""Create a SkillRegistry with the given skill configs registered."""
|
||
registry = SkillRegistry()
|
||
for config in configs:
|
||
registry.register(Skill(config=config, tools=[]))
|
||
return registry
|
||
|
||
|
||
class TestProgressiveSkillLoading:
|
||
"""U5: 渐进式技能加载 — disclosure_level 控制概要 vs 全量注入。"""
|
||
|
||
def test_level_0_summary_only_name_and_description(self) -> None:
|
||
"""disclosure_level=0 时,system_prompt 只含 skill name + description。"""
|
||
config = _make_skill_config(
|
||
name="research",
|
||
description="Research skill for gathering information",
|
||
disclosure_level=0,
|
||
)
|
||
prompt = build_skill_system_prompt(config)
|
||
|
||
assert prompt is not None
|
||
assert "## Skill: research" in prompt
|
||
assert "Research skill for gathering information" in prompt
|
||
# Should NOT contain full prompt sections
|
||
assert "You are a research expert" not in prompt
|
||
assert "Step 1: gather data" not in prompt
|
||
assert "Do not hallucinate" not in prompt
|
||
# Should hint at skill_detail tool
|
||
assert "skill_detail" in prompt
|
||
|
||
def test_level_0_with_preconditions_includes_guard(self) -> None:
|
||
"""disclosure_level=0 时,前置条件仍然注入(安全守卫)。"""
|
||
config = _make_skill_config(
|
||
name="dangerous_op",
|
||
description="A dangerous operation skill",
|
||
disclosure_level=0,
|
||
preconditions=["User must confirm the operation", "API key must be set"],
|
||
)
|
||
prompt = build_skill_system_prompt(config)
|
||
|
||
assert prompt is not None
|
||
assert "## Skill: dangerous_op" in prompt
|
||
assert "## Activation Preconditions" in prompt
|
||
assert "User must confirm the operation" in prompt
|
||
assert "API key must be set" in prompt
|
||
|
||
def test_level_1_full_prompt_backward_compatible(self) -> None:
|
||
"""disclosure_level=1(默认)时,行为与现状一致(全量加载)。"""
|
||
config = _make_skill_config(
|
||
name="research",
|
||
description="Research skill",
|
||
disclosure_level=1,
|
||
)
|
||
prompt = build_skill_system_prompt(config)
|
||
|
||
assert prompt is not None
|
||
assert "You are a research expert." in prompt
|
||
assert "Step 1: gather data. Step 2: analyze." in prompt
|
||
assert "Do not hallucinate." in prompt
|
||
assert "Markdown report." in prompt
|
||
# Should NOT contain the skill_detail hint
|
||
assert "skill_detail" not in prompt
|
||
|
||
def test_level_0_no_description_still_works(self) -> None:
|
||
"""disclosure_level=0 且无 description 时,仍能生成有效 prompt。"""
|
||
config = _make_skill_config(
|
||
name="minimal",
|
||
description="",
|
||
disclosure_level=0,
|
||
)
|
||
prompt = build_skill_system_prompt(config)
|
||
|
||
assert prompt is not None
|
||
assert "## Skill: minimal" in prompt
|
||
assert "skill_detail" in prompt
|
||
|
||
|
||
class TestSkillDetailTool:
|
||
"""U5: SkillDetailTool — 按需加载完整 skill instructions。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_exact_name_match_returns_full_instructions(self) -> None:
|
||
"""精确名称匹配 → 返回完整 instructions。"""
|
||
config = _make_skill_config(name="research", disclosure_level=0)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
tool = SkillDetailTool(skill_registry=registry)
|
||
result = await tool.execute(query="research")
|
||
|
||
assert result["name"] == "research"
|
||
assert result["description"] == "Research skill for gathering information"
|
||
assert "You are a research expert." in result["full_instructions"]
|
||
assert "Step 1: gather data" in result["full_instructions"]
|
||
assert "Do not hallucinate." in result["full_instructions"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keyword_search_returns_matching_skill(self) -> None:
|
||
"""关键词搜索 → 返回匹配的 skill 完整内容。"""
|
||
config1 = _make_skill_config(name="research", description="Research skill")
|
||
config2 = _make_skill_config(
|
||
name="code_review",
|
||
description="Review code quality",
|
||
)
|
||
registry = _make_skill_registry_with_skills(config1, config2)
|
||
|
||
tool = SkillDetailTool(skill_registry=registry)
|
||
result = await tool.execute(query="review")
|
||
|
||
assert result["name"] == "code_review"
|
||
assert "Review code quality" in result["description"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_match_returns_message(self) -> None:
|
||
"""无匹配 → 返回 'No skills matched' 提示。"""
|
||
config = _make_skill_config(name="research")
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
tool = SkillDetailTool(skill_registry=registry)
|
||
result = await tool.execute(query="nonexistent_skill")
|
||
|
||
assert result["count"] == 0
|
||
assert result["results"] == []
|
||
assert "No skills matched" in result["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_query_returns_error(self) -> None:
|
||
"""空 query → 返回错误。"""
|
||
config = _make_skill_config(name="research")
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
tool = SkillDetailTool(skill_registry=registry)
|
||
result = await tool.execute(query="")
|
||
|
||
assert "error" in result
|
||
assert "query parameter is required" in result["error"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_includes_preconditions_in_full_instructions(self) -> None:
|
||
"""完整 instructions 中包含前置条件(安全守卫)。"""
|
||
config = _make_skill_config(
|
||
name="dangerous_op",
|
||
preconditions=["User must confirm", "API key set"],
|
||
disclosure_level=0,
|
||
)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
tool = SkillDetailTool(skill_registry=registry)
|
||
result = await tool.execute(query="dangerous_op")
|
||
|
||
assert "### Activation Preconditions" in result["full_instructions"]
|
||
assert "User must confirm" in result["full_instructions"]
|
||
assert "API key set" in result["full_instructions"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_case_insensitive_keyword_search(self) -> None:
|
||
"""关键词搜索大小写不敏感。"""
|
||
config = _make_skill_config(name="Research", description="Research skill")
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
tool = SkillDetailTool(skill_registry=registry)
|
||
result = await tool.execute(query="RESEARCH")
|
||
|
||
assert result["name"] == "Research"
|
||
|
||
|
||
class TestResolveSkillRoutingProgressive:
|
||
"""U5: resolve_skill_routing 在 disclosure_level=0 时注入 skill_detail 工具。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_level_0_adds_skill_detail_tool(self) -> None:
|
||
"""disclosure_level=0 时,routing 结果包含 skill_detail 工具。"""
|
||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||
|
||
config = _make_skill_config(name="research", disclosure_level=0)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
result = await resolve_skill_routing(
|
||
content="@skill:research analyze the data",
|
||
skill_registry=registry,
|
||
default_tools=[],
|
||
default_system_prompt="default",
|
||
)
|
||
|
||
assert result.matched is True
|
||
assert result.skill_name == "research"
|
||
tool_names = [t.name for t in result.tools]
|
||
assert "skill_detail" in tool_names
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_level_1_does_not_add_skill_detail_tool(self) -> None:
|
||
"""disclosure_level=1(默认)时,不注入 skill_detail 工具(向后兼容)。"""
|
||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||
|
||
config = _make_skill_config(name="research", disclosure_level=1)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
result = await resolve_skill_routing(
|
||
content="@skill:research analyze the data",
|
||
skill_registry=registry,
|
||
default_tools=[],
|
||
default_system_prompt="default",
|
||
)
|
||
|
||
assert result.matched is True
|
||
tool_names = [t.name for t in result.tools]
|
||
assert "skill_detail" not in tool_names
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_level_0_system_prompt_is_summary(self) -> None:
|
||
"""disclosure_level=0 时,system_prompt 是概要模式。"""
|
||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||
|
||
config = _make_skill_config(name="research", disclosure_level=0)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
result = await resolve_skill_routing(
|
||
content="@skill:research analyze the data",
|
||
skill_registry=registry,
|
||
default_tools=[],
|
||
default_system_prompt="default",
|
||
)
|
||
|
||
assert result.system_prompt is not None
|
||
assert "## Skill: research" in result.system_prompt
|
||
# Full instructions should NOT be in the system prompt
|
||
assert "You are a research expert." not in result.system_prompt
|
||
|
||
|
||
class TestRequestPreprocessorProgressive:
|
||
"""U5: RequestPreprocessor._resolve_explicit_skill 在 disclosure_level=0 时注入 skill_detail。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_preprocessor_level_0_adds_skill_detail(self) -> None:
|
||
"""RequestPreprocessor 在 disclosure_level=0 时注入 skill_detail 工具。"""
|
||
from agentkit.chat.request_preprocessor import RequestPreprocessor
|
||
|
||
config = _make_skill_config(name="research", disclosure_level=0)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
preprocessor = RequestPreprocessor()
|
||
result = await preprocessor.preprocess(
|
||
content="@skill:research analyze the data",
|
||
skill_registry=registry,
|
||
default_tools=[],
|
||
default_system_prompt="default",
|
||
)
|
||
|
||
assert result.matched is True
|
||
assert result.skill_name == "research"
|
||
tool_names = [t.name for t in result.tools]
|
||
assert "skill_detail" in tool_names
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_preprocessor_level_1_no_skill_detail(self) -> None:
|
||
"""RequestPreprocessor 在 disclosure_level=1 时不注入 skill_detail(向后兼容)。"""
|
||
from agentkit.chat.request_preprocessor import RequestPreprocessor
|
||
|
||
config = _make_skill_config(name="research", disclosure_level=1)
|
||
registry = _make_skill_registry_with_skills(config)
|
||
|
||
preprocessor = RequestPreprocessor()
|
||
result = await preprocessor.preprocess(
|
||
content="@skill:research analyze the data",
|
||
skill_registry=registry,
|
||
default_tools=[],
|
||
default_system_prompt="default",
|
||
)
|
||
|
||
assert result.matched is True
|
||
tool_names = [t.name for t in result.tools]
|
||
assert "skill_detail" not in tool_names
|