ForcePilot/test/test_runtime_config_middleware_skills.py
肖泽涛 46b676db07 feat(skills): add skills management module
实现 Skills 管理功能,支持超级管理员导入、编辑、导出和删除技能包。

- 新增 Skill 数据模型(元数据存数据库,内容存文件系统)
- 新增 /api/system/skills/* 路由(权限分级:admin 可列表,superadmin 可管理)
- 新增 SelectedSkillsReadonlyBackend,运行时只读挂载到 /skills
- Agent 配置新增 skills 字段,运行时注入技能提示词
- 前端新增 Skills 管理页面(设置弹窗 + 独立路由)
- 新增测试覆盖:service/router/backend 单测

未实现功能:
 - 关联使用其他技能
 - 沙箱执行代码
 - 在技能中绑定工具
2026-02-21 02:48:20 +08:00

157 lines
5.3 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
import pytest
from langchain_core.messages import SystemMessage
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
from src.services import skill_service
@dataclass
class _FakeTool:
name: str
@dataclass
class _FakeRequest:
runtime: Any
tools: list[Any]
system_message: SystemMessage
def override(self, **kwargs):
return _FakeRequest(
runtime=kwargs.get("runtime", self.runtime),
tools=kwargs.get("tools", self.tools),
system_message=kwargs.get("system_message", self.system_message),
)
async def _echo_handler(request):
return request
def _build_request(*, skills: list[str], tools: list[str], system_prompt: str = "你是助手") -> _FakeRequest:
context = SimpleNamespace(system_prompt=system_prompt, skills=skills)
runtime = SimpleNamespace(context=context)
return _FakeRequest(
runtime=runtime,
tools=[_FakeTool(name=name) for name in tools],
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
)
def _extract_appended_prompt(request: _FakeRequest) -> str:
return request.system_message.content_blocks[-1]["text"]
def _build_middleware() -> RuntimeConfigMiddleware:
return RuntimeConfigMiddleware(
enable_model_override=False,
enable_tools_override=False,
enable_system_prompt_override=True,
enable_skills_prompt_override=True,
)
@pytest.mark.asyncio
async def test_injects_skills_section_when_skills_configured_and_read_file_available(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
runtime_middleware,
"get_skill_prompt_metadata_by_slugs",
lambda _slugs: [
{
"name": "research-report",
"description": "Write structured research reports",
"path": "/skills/research-report/SKILL.md",
}
],
)
middleware = _build_middleware()
request = _build_request(skills=["research-report"], tools=["read_file"])
result = await middleware.awrap_model_call(request, _echo_handler)
prompt = _extract_appended_prompt(result)
assert "## Skills System" in prompt
assert "**Skills Skills**: `/skills/` (higher priority)" in prompt
assert "- **research-report**: Write structured research reports" in prompt
assert "Read `/skills/research-report/SKILL.md` for full instructions" in prompt
assert "Recognize when a skill applies" in prompt
assert "当前时间:" in prompt
@pytest.mark.asyncio
async def test_skips_skills_section_when_context_skills_empty(monkeypatch: pytest.MonkeyPatch):
def _should_not_call(_slugs: list[str]):
raise AssertionError("should not query skills metadata when context.skills is empty")
monkeypatch.setattr(runtime_middleware, "get_skill_prompt_metadata_by_slugs", _should_not_call)
middleware = _build_middleware()
request = _build_request(skills=[], tools=["read_file"])
result = await middleware.awrap_model_call(request, _echo_handler)
prompt = _extract_appended_prompt(result)
assert "## Skills System" not in prompt
@pytest.mark.asyncio
async def test_skips_skills_section_without_read_file_and_logs_warning(monkeypatch: pytest.MonkeyPatch):
warnings: list[str] = []
fake_logger = SimpleNamespace(
debug=lambda *_args, **_kwargs: None,
warning=lambda message: warnings.append(message),
)
monkeypatch.setattr(runtime_middleware, "logger", fake_logger)
monkeypatch.setattr(
runtime_middleware,
"get_skill_prompt_metadata_by_slugs",
lambda _slugs: (_ for _ in ()).throw(AssertionError("should not query metadata without read_file")),
)
middleware = _build_middleware()
request = _build_request(skills=["research-report"], tools=["write_file"])
result = await middleware.awrap_model_call(request, _echo_handler)
prompt = _extract_appended_prompt(result)
assert "## Skills System" not in prompt
assert any("read_file unavailable" in msg for msg in warnings)
@pytest.mark.asyncio
async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
skill_service,
"_skill_prompt_metadata_cache",
{
"beta": {
"name": "beta",
"description": "beta skill",
"path": "/skills/beta/SKILL.md",
},
"alpha": {
"name": "alpha",
"description": "alpha skill",
"path": "/skills/alpha/SKILL.md",
},
},
)
middleware = _build_middleware()
request = _build_request(skills=["beta", "missing", "alpha", "beta"], tools=["read_file"])
result = await middleware.awrap_model_call(request, _echo_handler)
prompt = _extract_appended_prompt(result)
beta_line = "- **beta**: beta skill"
alpha_line = "- **alpha**: alpha skill"
assert beta_line in prompt
assert alpha_line in prompt
assert prompt.find(beta_line) < prompt.find(alpha_line)
assert prompt.count(beta_line) == 1
assert "missing" not in prompt