from pathlib import Path import pytest from app.ai_provider.types.messages import HumanMessage from app.application.agent import AgentContext, AgentRunContext, ExtensionRegistry from app.application.agent.python_skills import ( PythonSkillCatalog, PythonSkillDefinition, activate_python_skills, discover_python_skills, ) def _write_skill(package: Path, module: str, *, name: str, trigger: str) -> None: (package / f"{module}.py").write_text( f""" from app.application.agent.python_skills import PythonSkillDefinition from app.ai_provider.types.messages import HumanMessage async def transform(context, run): return type(context)( messages=(*context.messages, HumanMessage(content={name!r})), tools=context.tools, model_hint=context.model_hint, ) def setup(registry): registry.add_context_transformer(transform) SKILL = PythonSkillDefinition( name={name!r}, description={name!r}, triggers=({trigger!r},), instructions={name!r}, setup=setup, ) """, encoding="utf-8", ) async def test_catalog_discovers_without_activating_and_matches_per_run( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: package = tmp_path / "example_skills" package.mkdir() (package / "__init__.py").write_text("", encoding="utf-8") _write_skill(package, "b_skill", name="乙", trigger="研究") _write_skill(package, "a_skill", name="甲", trigger="审查") (package / "_ignored.py").write_text( "raise AssertionError('私有模块不应装载')", encoding="utf-8", ) monkeypatch.syspath_prepend(str(tmp_path)) catalog = discover_python_skills(("example_skills", "example_skills")) registry = ExtensionRegistry() unchanged = await registry.transform_context( AgentContext(messages=(HumanMessage(content="开始"),), tools=()), AgentRunContext(), ) matched = catalog.match("请做研究", skill_refs=("甲",)) activation = await activate_python_skills(registry, matched) transformed = await registry.transform_context( AgentContext(messages=(HumanMessage(content="开始"),), tools=()), AgentRunContext(), ) await activation.close() assert [skill.name for skill in catalog.skills] == ["甲", "乙"] assert [message.content for message in unchanged.messages] == ["开始"] assert [skill.name for skill in matched] == ["甲", "乙"] assert [message.content for message in transformed.messages] == ["开始", "甲", "乙"] async def test_activation_cleanup_is_run_scoped() -> None: cleaned: list[str] = [] def setup(registry: ExtensionRegistry): async def transform(context: AgentContext, _run: AgentRunContext) -> AgentContext: return AgentContext( (*context.messages, HumanMessage(content="active")), context.tools, context.model_hint, ) registry.add_context_transformer(transform) return lambda: cleaned.append("closed") skill = PythonSkillDefinition("guard", "", ("guard",), "", setup) catalog = PythonSkillCatalog((skill,)) first_registry = ExtensionRegistry() second_registry = ExtensionRegistry() activation = await activate_python_skills(first_registry, catalog.match("guard")) first = await first_registry.transform_context(AgentContext((), ()), AgentRunContext()) second = await second_registry.transform_context(AgentContext((), ()), AgentRunContext()) await activation.close() assert [message.content for message in first.messages] == ["active"] assert second.messages == () assert cleaned == ["closed"] def test_catalog_supports_custom_matcher_with_run_metadata() -> None: skill = PythonSkillDefinition( "metadata_skill", "", (), "", lambda _registry: None, match=lambda text, metadata: text == "执行" and metadata.get("channel") == "voice", ) catalog = PythonSkillCatalog((skill,)) assert catalog.match("执行", metadata={"channel": "text"}) == () assert catalog.match("执行", metadata={"channel": "voice"}) == (skill,) def test_catalog_rejects_duplicate_names() -> None: skill = PythonSkillDefinition("same", "", (), "", lambda _registry: None) with pytest.raises(ValueError, match="名称重复"): PythonSkillCatalog((skill, skill)) def test_discovery_rejects_module_without_definition( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: package = tmp_path / "invalid_skills" package.mkdir() (package / "__init__.py").write_text("", encoding="utf-8") (package / "broken.py").write_text("SKILL_NAME = '坏技能'", encoding="utf-8") monkeypatch.syspath_prepend(str(tmp_path)) with pytest.raises(TypeError, match="缺少 SKILL 定义"): discover_python_skills(("invalid_skills",)) async def test_budget_guard_skill_stops_when_usage_exceeds_threshold() -> None: from app.ai_provider.types.chat import TokenUsage from app.application.agent.extensions import TurnSnapshot from app.skills.budget_guard import _BUDGET_TOTAL_TOKENS, SKILL registry = ExtensionRegistry() activation = await activate_python_skills(registry, (SKILL,)) over_budget = TurnSnapshot( iteration=4, content="", reasoning="", tool_calls=(), tool_results=(), usage=TokenUsage(total_tokens=_BUDGET_TOTAL_TOKENS + 1), ) within_budget = TurnSnapshot( iteration=1, content="", reasoning="", tool_calls=(), tool_results=(), usage=TokenUsage(total_tokens=max(_BUDGET_TOTAL_TOKENS - 1, 0)), ) assert await registry.should_stop(over_budget, AgentRunContext()) is True assert await registry.should_stop(within_budget, AgentRunContext()) is False await activation.close() async def test_context_budget_folds_long_history() -> None: from app.ai_provider.types.messages import HumanMessage from app.application.agent.extensions import AgentContext from app.skills.context_budget import SKILL registry = ExtensionRegistry() activation = await activate_python_skills(registry, (SKILL,)) messages = tuple(HumanMessage(content="x" * 1000) for _ in range(20)) ctx = AgentContext(messages=messages, tools=()) trimmed = await registry.transform_context(ctx, AgentRunContext()) assert len(trimmed.messages) < len(messages) assert "已省略" in trimmed.messages[0].content await activation.close() async def test_arg_guard_rejects_oversized_input() -> None: from app.application.agent.extensions import ToolCallContext from app.skills.arg_guard import _MAX_ARG_CHARS, SKILL registry = ExtensionRegistry() activation = await activate_python_skills(registry, (SKILL,)) oversized = ToolCallContext( "call_1", "write_file", {"content": "x" * (_MAX_ARG_CHARS + 10)}, () ) normal = ToolCallContext("call_2", "read_file", {"path": "/a"}, ()) big = await registry.before_tool_call(oversized, AgentRunContext()) small = await registry.before_tool_call(normal, AgentRunContext()) assert big.allowed is False assert big.reason is not None assert small.allowed is True await activation.close() async def test_run_metrics_listens_through_settled() -> None: from app.application.agent.extensions import AgentEvent from app.skills.run_metrics import SKILL registry = ExtensionRegistry() activation = await activate_python_skills(registry, (SKILL,)) run = AgentRunContext(run_id="run_metrics_test") await registry.publish(AgentEvent("agent.started"), run) await registry.publish(AgentEvent("agent.turn.started", {"iteration": 1}), run) await registry.publish( AgentEvent("tool.started", {"tool_call_id": "c1", "tool_name": "search"}), run, ) await registry.publish( AgentEvent("tool.completed", {"tool_call_id": "c1", "status": "success"}), run, ) await registry.publish( AgentEvent("tool.completed", {"tool_call_id": "c2", "status": "denied"}), run, ) await registry.publish(AgentEvent("agent.turn.started", {"iteration": 2}), run) await registry.publish(AgentEvent("agent.settled", {"status": "completed"}), run) await activation.close() # 指标经 loguru 输出;此处验证含工具级事件的事件链被完整消费且 settled 不抛错。