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