fischer-agentkit/tests/unit/chat/test_skill_routing.py

428 lines
16 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.

"""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