feat(skills): 支持依赖管理与运行时渐进注入
This commit is contained in:
parent
85b28f3753
commit
2666b27fcb
@ -15,10 +15,12 @@ from src.services.skill_service import (
|
||||
delete_skill,
|
||||
delete_skill_node,
|
||||
export_skill_zip,
|
||||
get_skill_dependency_options,
|
||||
get_skill_tree,
|
||||
import_skill_zip,
|
||||
list_skills,
|
||||
read_skill_file,
|
||||
update_skill_dependencies,
|
||||
update_skill_file,
|
||||
)
|
||||
from src.storage.postgres.models_business import User
|
||||
@ -38,6 +40,12 @@ class SkillFileUpdateRequest(BaseModel):
|
||||
content: str = Field(..., description="文件内容")
|
||||
|
||||
|
||||
class SkillDependenciesUpdateRequest(BaseModel):
|
||||
tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表")
|
||||
mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表")
|
||||
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
|
||||
|
||||
|
||||
def _raise_from_value_error(e: ValueError) -> None:
|
||||
message = str(e)
|
||||
status_code = 404 if "不存在" in message else 400
|
||||
@ -58,6 +66,18 @@ async def list_skills_route(
|
||||
raise HTTPException(status_code=500, detail="获取技能列表失败")
|
||||
|
||||
|
||||
@skills.get("/dependency-options")
|
||||
async def get_skill_dependency_options_route(
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
):
|
||||
"""获取 skill 依赖项可选列表(仅超级管理员)。"""
|
||||
try:
|
||||
return {"success": True, "data": get_skill_dependency_options()}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get skill dependency options: {e}")
|
||||
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
||||
|
||||
|
||||
@skills.post("/import")
|
||||
async def import_skill_route(
|
||||
file: UploadFile = File(...),
|
||||
@ -175,6 +195,33 @@ async def update_skill_file_route(
|
||||
raise HTTPException(status_code=500, detail="更新技能文件失败")
|
||||
|
||||
|
||||
@skills.put("/{slug}/dependencies")
|
||||
async def update_skill_dependencies_route(
|
||||
slug: str,
|
||||
payload: SkillDependenciesUpdateRequest,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 skill 依赖(仅超级管理员)。"""
|
||||
try:
|
||||
item = await update_skill_dependencies(
|
||||
db,
|
||||
slug=slug,
|
||||
tool_dependencies=payload.tool_dependencies,
|
||||
mcp_dependencies=payload.mcp_dependencies,
|
||||
skill_dependencies=payload.skill_dependencies,
|
||||
updated_by=current_user.username,
|
||||
)
|
||||
return {"success": True, "data": item.to_dict()}
|
||||
except ValueError as e:
|
||||
_raise_from_value_error(e)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill dependencies '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="更新 skill 依赖失败")
|
||||
|
||||
|
||||
@skills.delete("/{slug}/file")
|
||||
async def delete_skill_file_route(
|
||||
slug: str,
|
||||
|
||||
@ -17,6 +17,7 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
pg_manager.initialize()
|
||||
await pg_manager.create_business_tables()
|
||||
await pg_manager.ensure_business_schema()
|
||||
await pg_manager.ensure_knowledge_schema()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database during startup: {e}")
|
||||
|
||||
@ -6,7 +6,7 @@ from typing import Any
|
||||
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
|
||||
from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileUploadResponse, WriteResult
|
||||
|
||||
from src.services.skill_service import get_skills_root_dir
|
||||
from src.services.skill_service import get_expanded_visible_skill_slugs, get_skills_root_dir
|
||||
|
||||
|
||||
class SelectedSkillsReadonlyBackend(FilesystemBackend):
|
||||
@ -120,9 +120,10 @@ class SelectedSkillsReadonlyBackend(FilesystemBackend):
|
||||
def create_agent_composite_backend(runtime) -> CompositeBackend:
|
||||
"""为 agent 构建 backend:默认 StateBackend + /skills 路由只读 backend。"""
|
||||
selected_skills = getattr(runtime.context, "skills", None)
|
||||
visible_skills = get_expanded_visible_skill_slugs(selected_skills or [])
|
||||
return CompositeBackend(
|
||||
default=StateBackend(runtime),
|
||||
routes={
|
||||
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=selected_skills),
|
||||
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
|
||||
},
|
||||
)
|
||||
|
||||
@ -28,6 +28,9 @@ class SkillRepository:
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str,
|
||||
tool_dependencies: list[str] | None,
|
||||
mcp_dependencies: list[str] | None,
|
||||
skill_dependencies: list[str] | None,
|
||||
dir_path: str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
@ -36,6 +39,9 @@ class SkillRepository:
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
tool_dependencies=tool_dependencies or [],
|
||||
mcp_dependencies=mcp_dependencies or [],
|
||||
skill_dependencies=skill_dependencies or [],
|
||||
dir_path=dir_path,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
@ -47,6 +53,24 @@ class SkillRepository:
|
||||
await self.db.refresh(item)
|
||||
return item
|
||||
|
||||
async def update_dependencies(
|
||||
self,
|
||||
item: Skill,
|
||||
*,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
item.tool_dependencies = tool_dependencies
|
||||
item.mcp_dependencies = mcp_dependencies
|
||||
item.skill_dependencies = skill_dependencies
|
||||
item.updated_by = updated_by
|
||||
item.updated_at = utc_now_naive()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(item)
|
||||
return item
|
||||
|
||||
async def update_metadata(
|
||||
self,
|
||||
item: Skill,
|
||||
|
||||
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import config as sys_config
|
||||
from src.repositories.skill_repository import SkillRepository
|
||||
from src.services.mcp_service import get_mcp_server_names
|
||||
from src.storage.postgres.manager import pg_manager
|
||||
from src.storage.postgres.models_business import Skill
|
||||
from src.utils.logging_config import logger
|
||||
@ -52,6 +53,29 @@ TEXT_FILE_EXTENSIONS = {
|
||||
|
||||
_skill_options_cache: list[dict[str, str]] = []
|
||||
_skill_prompt_metadata_cache: dict[str, dict[str, str]] = {}
|
||||
_skill_dependency_cache: dict[str, dict[str, list[str]]] = {}
|
||||
|
||||
|
||||
def _normalize_string_list(values: list[str] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
item = value.strip()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_buildin_tool_names() -> list[str]:
|
||||
from src.agents.common.tools import get_buildin_tools
|
||||
|
||||
return [tool.name for tool in get_buildin_tools()]
|
||||
|
||||
|
||||
def get_skills_root_dir() -> Path:
|
||||
@ -65,6 +89,14 @@ def get_skill_options() -> list[dict[str, str]]:
|
||||
return list(_skill_options_cache)
|
||||
|
||||
|
||||
def get_skill_dependency_options() -> dict[str, list[str]]:
|
||||
return {
|
||||
"tools": _get_buildin_tool_names(),
|
||||
"mcps": get_mcp_server_names(),
|
||||
"skills": [item["id"] for item in _skill_options_cache],
|
||||
}
|
||||
|
||||
|
||||
def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]:
|
||||
"""按 slug 顺序返回 skills prompt 元数据(仅缓存,无 IO)。"""
|
||||
if not slugs:
|
||||
@ -87,8 +119,68 @@ def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]
|
||||
return result
|
||||
|
||||
|
||||
def expand_skill_closure(slugs: list[str]) -> list[str]:
|
||||
"""递归展开 skill 依赖(仅缓存,无 IO),去重保序并去环。"""
|
||||
ordered_roots = _normalize_string_list(slugs)
|
||||
if not ordered_roots:
|
||||
return []
|
||||
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def dfs(slug: str, stack: set[str]) -> None:
|
||||
if slug in stack:
|
||||
logger.warning(f"Cycle detected in skill dependencies, skip: {' -> '.join([*stack, slug])}")
|
||||
return
|
||||
if slug in seen:
|
||||
return
|
||||
|
||||
node = _skill_dependency_cache.get(slug)
|
||||
if not node:
|
||||
logger.warning(f"Skill dependency target not found in cache, skip: {slug}")
|
||||
return
|
||||
|
||||
seen.add(slug)
|
||||
result.append(slug)
|
||||
next_stack = set(stack)
|
||||
next_stack.add(slug)
|
||||
for dep in node.get("skills", []):
|
||||
dfs(dep, next_stack)
|
||||
|
||||
for root in ordered_roots:
|
||||
dfs(root, set())
|
||||
return result
|
||||
|
||||
|
||||
def get_dependency_bundle_for_activated_skills(activated_slugs: list[str]) -> dict[str, list[str]]:
|
||||
closure = expand_skill_closure(activated_slugs)
|
||||
tools: list[str] = []
|
||||
mcps: list[str] = []
|
||||
seen_tools: set[str] = set()
|
||||
seen_mcps: set[str] = set()
|
||||
|
||||
for slug in closure:
|
||||
dep = _skill_dependency_cache.get(slug, {})
|
||||
for tool_name in dep.get("tools", []):
|
||||
if tool_name in seen_tools:
|
||||
continue
|
||||
seen_tools.add(tool_name)
|
||||
tools.append(tool_name)
|
||||
for server_name in dep.get("mcps", []):
|
||||
if server_name in seen_mcps:
|
||||
continue
|
||||
seen_mcps.add(server_name)
|
||||
mcps.append(server_name)
|
||||
return {"tools": tools, "mcps": mcps, "skills": closure}
|
||||
|
||||
|
||||
def get_expanded_visible_skill_slugs(selected_slugs: list[str]) -> list[str]:
|
||||
"""展开运行时可见 skills(根 skills + 递归依赖)。"""
|
||||
return expand_skill_closure(selected_slugs)
|
||||
|
||||
|
||||
def _set_skill_options_cache(items: list[Skill]) -> None:
|
||||
global _skill_options_cache, _skill_prompt_metadata_cache
|
||||
global _skill_options_cache, _skill_prompt_metadata_cache, _skill_dependency_cache
|
||||
_skill_options_cache = [
|
||||
{
|
||||
"id": item.slug,
|
||||
@ -105,6 +197,14 @@ def _set_skill_options_cache(items: list[Skill]) -> None:
|
||||
}
|
||||
for item in items
|
||||
}
|
||||
_skill_dependency_cache = {
|
||||
item.slug: {
|
||||
"tools": _normalize_string_list(item.tool_dependencies or []),
|
||||
"mcps": _normalize_string_list(item.mcp_dependencies or []),
|
||||
"skills": _normalize_string_list(item.skill_dependencies or []),
|
||||
}
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
async def init_skills_cache() -> None:
|
||||
@ -126,6 +226,69 @@ async def list_skills(db: AsyncSession) -> list[Skill]:
|
||||
return items
|
||||
|
||||
|
||||
def _validate_dependencies(
|
||||
*,
|
||||
slug: str,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
tools = _normalize_string_list(tool_dependencies)
|
||||
mcps = _normalize_string_list(mcp_dependencies)
|
||||
skills = _normalize_string_list(skill_dependencies)
|
||||
|
||||
available_tools = set(_get_buildin_tool_names())
|
||||
invalid_tools = [name for name in tools if name not in available_tools]
|
||||
if invalid_tools:
|
||||
raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}")
|
||||
|
||||
available_mcps = set(get_mcp_server_names())
|
||||
invalid_mcps = [name for name in mcps if name not in available_mcps]
|
||||
if invalid_mcps:
|
||||
raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}")
|
||||
|
||||
available_skills = {item["id"] for item in _skill_options_cache}
|
||||
invalid_skills = [name for name in skills if name not in available_skills]
|
||||
if invalid_skills:
|
||||
raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}")
|
||||
|
||||
if slug in skills:
|
||||
raise ValueError("skill_dependencies 不允许包含自身")
|
||||
|
||||
return tools, mcps, skills
|
||||
|
||||
|
||||
async def update_skill_dependencies(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
repo = SkillRepository(db)
|
||||
# 写操作前先同步一次缓存,确保依赖校验基于最新技能集合。
|
||||
_set_skill_options_cache(await repo.list_all())
|
||||
tools, mcps, skills = _validate_dependencies(
|
||||
slug=slug,
|
||||
tool_dependencies=tool_dependencies,
|
||||
mcp_dependencies=mcp_dependencies,
|
||||
skill_dependencies=skill_dependencies,
|
||||
)
|
||||
|
||||
updated = await repo.update_dependencies(
|
||||
item,
|
||||
tool_dependencies=tools,
|
||||
mcp_dependencies=mcps,
|
||||
skill_dependencies=skills,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
_set_skill_options_cache(await repo.list_all())
|
||||
return updated
|
||||
|
||||
|
||||
def _validate_skill_name(name: str) -> str:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
@ -312,6 +475,9 @@ async def import_skill_zip(
|
||||
slug=final_slug,
|
||||
name=final_name,
|
||||
description=parsed_desc,
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path=(Path("skills") / final_slug).as_posix(),
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
@ -164,6 +164,18 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
async def ensure_business_schema(self):
|
||||
"""确保业务 schema 包含后续新增字段(兼容已存在表)。"""
|
||||
self._check_initialized()
|
||||
stmts = [
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
]
|
||||
async with self.async_engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
@property
|
||||
def is_postgresql(self) -> bool:
|
||||
"""检查是否是 PostgreSQL 数据库"""
|
||||
|
||||
@ -180,6 +180,9 @@ class Skill(Base):
|
||||
slug = Column(String(128), nullable=False, unique=True, index=True, comment="技能唯一标识(目录名)")
|
||||
name = Column(String(128), nullable=False, comment="技能名称(来自 SKILL.md frontmatter.name)")
|
||||
description = Column(Text, nullable=False, comment="技能描述(来自 SKILL.md frontmatter.description)")
|
||||
tool_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的内置工具名列表")
|
||||
mcp_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的 MCP 服务名列表")
|
||||
skill_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的其他 skill slug 列表")
|
||||
dir_path = Column(String(512), nullable=False, comment="技能目录路径(相对 save_dir)")
|
||||
created_by = Column(String(64), nullable=True)
|
||||
updated_by = Column(String(64), nullable=True)
|
||||
@ -192,6 +195,9 @@ class Skill(Base):
|
||||
"slug": self.slug,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"tool_dependencies": self.tool_dependencies or [],
|
||||
"mcp_dependencies": self.mcp_dependencies or [],
|
||||
"skill_dependencies": self.skill_dependencies or [],
|
||||
"dir_path": self.dir_path,
|
||||
"created_by": self.created_by,
|
||||
"updated_by": self.updated_by,
|
||||
|
||||
@ -5,7 +5,8 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
|
||||
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
|
||||
@ -22,12 +23,14 @@ class _FakeRequest:
|
||||
runtime: Any
|
||||
tools: list[Any]
|
||||
system_message: SystemMessage
|
||||
state: dict[str, Any]
|
||||
|
||||
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),
|
||||
state=kwargs.get("state", self.state),
|
||||
)
|
||||
|
||||
|
||||
@ -42,6 +45,7 @@ def _build_request(*, skills: list[str], tools: list[str], system_prompt: str =
|
||||
runtime=runtime,
|
||||
tools=[_FakeTool(name=name) for name in tools],
|
||||
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
|
||||
state={},
|
||||
)
|
||||
|
||||
|
||||
@ -58,6 +62,11 @@ def _build_middleware() -> RuntimeConfigMiddleware:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeToolCallRequest:
|
||||
tool_call: dict[str, Any]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injects_skills_section_when_skills_configured_and_read_file_available(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
@ -154,3 +163,89 @@ async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped
|
||||
assert prompt.find(beta_line) < prompt.find(alpha_line)
|
||||
assert prompt.count(beta_line) == 1
|
||||
assert "missing" not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_activates_skill_when_read_skill_md():
|
||||
middleware = _build_middleware()
|
||||
request = _FakeToolCallRequest(
|
||||
tool_call={
|
||||
"name": "read_file",
|
||||
"args": {"file_path": "/skills/research-report/SKILL.md"},
|
||||
}
|
||||
)
|
||||
|
||||
async def _handler(_request):
|
||||
return ToolMessage(content="ok", tool_call_id="tc-1")
|
||||
|
||||
result = await middleware.awrap_tool_call(request, _handler)
|
||||
assert isinstance(result, Command)
|
||||
assert result.update["activated_skills"] == ["research-report"]
|
||||
assert len(result.update["messages"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrap_tool_call_merges_with_existing_command_update():
|
||||
middleware = _build_middleware()
|
||||
request = _FakeToolCallRequest(
|
||||
tool_call={
|
||||
"name": "read_file",
|
||||
"args": {"file_path": "/skills/research-report/SKILL.md"},
|
||||
}
|
||||
)
|
||||
|
||||
async def _handler(_request):
|
||||
return Command(update={"messages": [ToolMessage(content="ok", tool_call_id="tc-1")], "activated_skills": ["a"]})
|
||||
|
||||
result = await middleware.awrap_tool_call(request, _handler)
|
||||
assert isinstance(result, Command)
|
||||
assert result.update["activated_skills"] == ["a", "research-report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
runtime_middleware,
|
||||
"get_buildin_tools",
|
||||
lambda: [_FakeTool(name="calculator"), _FakeTool(name="dep-tool")],
|
||||
)
|
||||
monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: [])
|
||||
monkeypatch.setattr(
|
||||
runtime_middleware,
|
||||
"get_dependency_bundle_for_activated_skills",
|
||||
lambda activated: {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": activated},
|
||||
)
|
||||
|
||||
async def fake_get_enabled_mcp_tools(server_name: str):
|
||||
if server_name == "mcp-a":
|
||||
return [_FakeTool(name="mcp_tool")]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(runtime_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools)
|
||||
|
||||
middleware = RuntimeConfigMiddleware(
|
||||
extra_tools=[_FakeTool(name="mcp_tool")],
|
||||
enable_model_override=False,
|
||||
enable_tools_override=True,
|
||||
enable_system_prompt_override=False,
|
||||
enable_skills_prompt_override=False,
|
||||
)
|
||||
|
||||
context = SimpleNamespace(system_prompt="x", skills=[], tools=[], knowledges=[], mcps=[])
|
||||
request = _FakeRequest(
|
||||
runtime=SimpleNamespace(context=context),
|
||||
tools=[
|
||||
_FakeTool(name="calculator"),
|
||||
_FakeTool(name="dep-tool"),
|
||||
_FakeTool(name="mcp_tool"),
|
||||
_FakeTool(name="read_file"),
|
||||
],
|
||||
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
|
||||
state={"activated_skills": ["alpha"]},
|
||||
)
|
||||
|
||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
||||
tool_names = [t.name for t in result.tools]
|
||||
assert "dep-tool" in tool_names
|
||||
assert "mcp_tool" in tool_names
|
||||
assert "calculator" not in tool_names
|
||||
|
||||
@ -97,3 +97,69 @@ def test_update_skill_file_passes_operator(monkeypatch):
|
||||
assert captured["slug"] == "demo"
|
||||
assert captured["relative_path"] == "SKILL.md"
|
||||
assert captured["updated_by"] == "root"
|
||||
|
||||
|
||||
def test_dependency_options_route(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"server.routers.skill_router.get_skill_dependency_options",
|
||||
lambda: {
|
||||
"tools": ["calculator"],
|
||||
"mcps": ["mcp-a"],
|
||||
"skills": ["demo"],
|
||||
},
|
||||
)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/system/skills/dependency-options")
|
||||
assert resp.status_code == 200, resp.text
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["tools"] == ["calculator"]
|
||||
|
||||
|
||||
def test_update_skill_dependencies_route(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_update_skill_dependencies(
|
||||
_db,
|
||||
*,
|
||||
slug,
|
||||
tool_dependencies,
|
||||
mcp_dependencies,
|
||||
skill_dependencies,
|
||||
updated_by,
|
||||
):
|
||||
captured["slug"] = slug
|
||||
captured["tool_dependencies"] = tool_dependencies
|
||||
captured["mcp_dependencies"] = mcp_dependencies
|
||||
captured["skill_dependencies"] = skill_dependencies
|
||||
captured["updated_by"] = updated_by
|
||||
return Skill(
|
||||
slug=slug,
|
||||
name=slug,
|
||||
description="demo",
|
||||
dir_path=f"skills/{slug}",
|
||||
tool_dependencies=tool_dependencies,
|
||||
mcp_dependencies=mcp_dependencies,
|
||||
skill_dependencies=skill_dependencies,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/system/skills/demo/dependencies",
|
||||
json={
|
||||
"tool_dependencies": ["calculator"],
|
||||
"mcp_dependencies": ["mcp-a"],
|
||||
"skill_dependencies": ["other-skill"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["slug"] == "demo"
|
||||
assert captured["tool_dependencies"] == ["calculator"]
|
||||
assert captured["mcp_dependencies"] == ["mcp-a"]
|
||||
assert captured["skill_dependencies"] == ["other-skill"]
|
||||
assert captured["updated_by"] == "root"
|
||||
|
||||
@ -52,6 +52,57 @@ def test_get_skill_prompt_metadata_by_slugs_dedup_and_skip_missing(monkeypatch:
|
||||
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
|
||||
|
||||
|
||||
def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"])
|
||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"_skill_options_cache",
|
||||
[
|
||||
{"id": "alpha", "name": "alpha", "description": "a"},
|
||||
{"id": "beta", "name": "beta", "description": "b"},
|
||||
],
|
||||
)
|
||||
|
||||
result = svc.get_skill_dependency_options()
|
||||
assert result["tools"] == ["calculator", "search"]
|
||||
assert result["mcps"] == ["mcp-a", "mcp-b"]
|
||||
assert result["skills"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_expand_skill_closure_and_dependency_bundle(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"_skill_dependency_cache",
|
||||
{
|
||||
"alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]},
|
||||
"beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]},
|
||||
"gamma": {"tools": ["t3"], "mcps": [], "skills": []},
|
||||
},
|
||||
)
|
||||
|
||||
closure = svc.expand_skill_closure(["alpha"])
|
||||
assert closure == ["alpha", "beta", "gamma"]
|
||||
|
||||
bundle = svc.get_dependency_bundle_for_activated_skills(["alpha"])
|
||||
assert bundle["skills"] == ["alpha", "beta", "gamma"]
|
||||
assert bundle["tools"] == ["t1", "t2", "t3"]
|
||||
assert bundle["mcps"] == ["m1", "m2"]
|
||||
|
||||
|
||||
def test_expand_skill_closure_cycle(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"_skill_dependency_cache",
|
||||
{
|
||||
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
|
||||
"beta": {"tools": [], "mcps": [], "skills": ["alpha"]},
|
||||
},
|
||||
)
|
||||
# 不应抛异常,并且去重保序
|
||||
assert svc.expand_skill_closure(["alpha"]) == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_resolve_relative_path_blocks_traversal(tmp_path: Path):
|
||||
skill_dir = tmp_path / "skill"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
@ -80,6 +131,9 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str,
|
||||
tool_dependencies: list[str] | None,
|
||||
mcp_dependencies: list[str] | None,
|
||||
skill_dependencies: list[str] | None,
|
||||
dir_path: str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
@ -87,6 +141,9 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
tool_dependencies=tool_dependencies or [],
|
||||
mcp_dependencies=mcp_dependencies or [],
|
||||
skill_dependencies=skill_dependencies or [],
|
||||
dir_path=dir_path,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
@ -193,3 +250,85 @@ async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytes
|
||||
assert updates["updated_by"] == "admin"
|
||||
saved_content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "description: updated desc" in saved_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
|
||||
item = Skill(
|
||||
slug="alpha",
|
||||
name="alpha",
|
||||
description="alpha",
|
||||
dir_path="skills/alpha",
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
)
|
||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
|
||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"_skill_options_cache",
|
||||
[
|
||||
{"id": "alpha", "name": "alpha", "description": "a"},
|
||||
{"id": "beta", "name": "beta", "description": "b"},
|
||||
],
|
||||
)
|
||||
|
||||
async def fake_get_skill_or_raise(_db, slug: str):
|
||||
assert slug == "alpha"
|
||||
return item
|
||||
|
||||
captured: dict[str, list[str] | str | None] = {}
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def list_all(self):
|
||||
return [
|
||||
item,
|
||||
Skill(
|
||||
slug="beta",
|
||||
name="beta",
|
||||
description="beta",
|
||||
dir_path="skills/beta",
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
),
|
||||
]
|
||||
|
||||
async def update_dependencies(
|
||||
self,
|
||||
_item: Skill,
|
||||
*,
|
||||
tool_dependencies: list[str],
|
||||
mcp_dependencies: list[str],
|
||||
skill_dependencies: list[str],
|
||||
updated_by: str | None,
|
||||
):
|
||||
captured["tool_dependencies"] = tool_dependencies
|
||||
captured["mcp_dependencies"] = mcp_dependencies
|
||||
captured["skill_dependencies"] = skill_dependencies
|
||||
captured["updated_by"] = updated_by
|
||||
_item.tool_dependencies = tool_dependencies
|
||||
_item.mcp_dependencies = mcp_dependencies
|
||||
_item.skill_dependencies = skill_dependencies
|
||||
return _item
|
||||
|
||||
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
updated = await svc.update_skill_dependencies(
|
||||
None,
|
||||
slug="alpha",
|
||||
tool_dependencies=["calculator", "calculator"],
|
||||
mcp_dependencies=["mcp-a", "mcp-a"],
|
||||
skill_dependencies=["beta", "beta"],
|
||||
updated_by="root",
|
||||
)
|
||||
assert captured["tool_dependencies"] == ["calculator"]
|
||||
assert captured["mcp_dependencies"] == ["mcp-a"]
|
||||
assert captured["skill_dependencies"] == ["beta"]
|
||||
assert captured["updated_by"] == "root"
|
||||
assert updated.skill_dependencies == ["beta"]
|
||||
|
||||
@ -49,6 +49,7 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo
|
||||
def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
|
||||
_prepare_skills_dir(tmp_path)
|
||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(skills_backend, "get_expanded_visible_skill_slugs", lambda slugs: ["alpha", "beta"])
|
||||
|
||||
runtime = SimpleNamespace(
|
||||
context=SimpleNamespace(skills=["alpha"]),
|
||||
@ -62,7 +63,7 @@ def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
|
||||
|
||||
skills_root = composite.ls_info("/skills/")
|
||||
skill_paths = sorted(entry.get("path") for entry in skills_root)
|
||||
assert skill_paths == ["/skills/alpha/"]
|
||||
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]
|
||||
|
||||
denied = composite.write("/skills/alpha/new.md", "x")
|
||||
assert denied.error and "read-only" in denied.error
|
||||
|
||||
@ -18,6 +18,10 @@ export const importSkillZip = async (file) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/import`, formData)
|
||||
}
|
||||
|
||||
export const getSkillDependencyOptions = async () => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
|
||||
}
|
||||
|
||||
export const getSkillTree = async (slug) => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
|
||||
}
|
||||
@ -34,6 +38,10 @@ export const updateSkillFile = async (slug, payload) => {
|
||||
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
|
||||
}
|
||||
|
||||
export const updateSkillDependencies = async (slug, payload) => {
|
||||
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
|
||||
}
|
||||
|
||||
export const deleteSkillFile = async (slug, path) => {
|
||||
return apiSuperAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
@ -49,10 +57,12 @@ export const deleteSkill = async (slug) => {
|
||||
export const skillApi = {
|
||||
listSkills,
|
||||
importSkillZip,
|
||||
getSkillDependencyOptions,
|
||||
getSkillTree,
|
||||
getSkillFile,
|
||||
createSkillFile,
|
||||
updateSkillFile,
|
||||
updateSkillDependencies,
|
||||
deleteSkillFile,
|
||||
exportSkill,
|
||||
deleteSkill
|
||||
|
||||
@ -41,6 +41,11 @@
|
||||
<div class="detail-title">
|
||||
<strong>{{ currentSkill.name }}</strong>
|
||||
<span class="slug">({{ currentSkill.slug }})</span>
|
||||
<span class="dependency-summary">
|
||||
工具 {{ (currentSkill.tool_dependencies || []).length }} · MCP
|
||||
{{ (currentSkill.mcp_dependencies || []).length }} · Skills
|
||||
{{ (currentSkill.skill_dependencies || []).length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<a-button size="small" @click="reloadTree">刷新目录</a-button>
|
||||
@ -51,6 +56,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dependency-panel">
|
||||
<div class="dependency-header">
|
||||
<span class="dependency-title">依赖管理</span>
|
||||
<a-button type="primary" size="small" :loading="savingDependencies" @click="saveDependencies">
|
||||
保存依赖
|
||||
</a-button>
|
||||
</div>
|
||||
<a-form layout="vertical" class="dependency-form">
|
||||
<a-form-item label="工具依赖">
|
||||
<a-select
|
||||
v-model:value="dependencyForm.tool_dependencies"
|
||||
mode="multiple"
|
||||
:options="toolDependencyOptions"
|
||||
placeholder="选择工具依赖"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="MCP 依赖">
|
||||
<a-select
|
||||
v-model:value="dependencyForm.mcp_dependencies"
|
||||
mode="multiple"
|
||||
:options="mcpDependencyOptions"
|
||||
placeholder="选择 MCP 服务依赖"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill 依赖">
|
||||
<a-select
|
||||
v-model:value="dependencyForm.skill_dependencies"
|
||||
mode="multiple"
|
||||
:options="skillDependencyOptions"
|
||||
placeholder="选择 Skill 依赖"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<div class="tree-panel">
|
||||
<div class="tree-toolbar">
|
||||
@ -129,6 +172,7 @@ const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const savingFile = ref(false)
|
||||
const creatingNode = ref(false)
|
||||
const savingDependencies = ref(false)
|
||||
|
||||
const skills = ref([])
|
||||
const currentSkill = ref(null)
|
||||
@ -145,10 +189,27 @@ const createForm = reactive({
|
||||
isDir: false,
|
||||
content: ''
|
||||
})
|
||||
const dependencyOptions = reactive({
|
||||
tools: [],
|
||||
mcps: [],
|
||||
skills: []
|
||||
})
|
||||
const dependencyForm = reactive({
|
||||
tool_dependencies: [],
|
||||
mcp_dependencies: [],
|
||||
skill_dependencies: []
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true },
|
||||
{ title: 'Slug', dataIndex: 'slug', key: 'slug', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '依赖',
|
||||
key: 'dependencies',
|
||||
width: 150,
|
||||
customRender: ({ record }) =>
|
||||
`T${(record.tool_dependencies || []).length} / M${(record.mcp_dependencies || []).length} / S${(record.skill_dependencies || []).length}`
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180, ellipsis: true }
|
||||
]
|
||||
@ -162,6 +223,20 @@ const rowClassName = (record) => {
|
||||
return currentSkill.value?.slug === record.slug ? 'selected-row' : ''
|
||||
}
|
||||
|
||||
const toolDependencyOptions = computed(() =>
|
||||
(dependencyOptions.tools || []).map((item) => ({ label: item, value: item }))
|
||||
)
|
||||
|
||||
const mcpDependencyOptions = computed(() =>
|
||||
(dependencyOptions.mcps || []).map((item) => ({ label: item, value: item }))
|
||||
)
|
||||
|
||||
const skillDependencyOptions = computed(() =>
|
||||
(dependencyOptions.skills || [])
|
||||
.filter((slug) => slug !== currentSkill.value?.slug)
|
||||
.map((item) => ({ label: item, value: item }))
|
||||
)
|
||||
|
||||
const bindSkillRow = (record) => ({
|
||||
onClick: () => selectSkill(record)
|
||||
})
|
||||
@ -199,8 +274,10 @@ const fetchSkills = async () => {
|
||||
resetFileState()
|
||||
} else {
|
||||
currentSkill.value = latest
|
||||
syncDependencyFormFromSkill(latest)
|
||||
}
|
||||
}
|
||||
await fetchDependencyOptions()
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取 Skills 列表失败')
|
||||
} finally {
|
||||
@ -208,6 +285,24 @@ const fetchSkills = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDependencyOptions = async () => {
|
||||
try {
|
||||
const result = await skillApi.getSkillDependencyOptions()
|
||||
const data = result?.data || {}
|
||||
dependencyOptions.tools = data.tools || []
|
||||
dependencyOptions.mcps = data.mcps || []
|
||||
dependencyOptions.skills = data.skills || []
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取依赖选项失败')
|
||||
}
|
||||
}
|
||||
|
||||
const syncDependencyFormFromSkill = (skillRecord) => {
|
||||
dependencyForm.tool_dependencies = [...(skillRecord?.tool_dependencies || [])]
|
||||
dependencyForm.mcp_dependencies = [...(skillRecord?.mcp_dependencies || [])]
|
||||
dependencyForm.skill_dependencies = [...(skillRecord?.skill_dependencies || [])]
|
||||
}
|
||||
|
||||
const reloadTree = async () => {
|
||||
if (!currentSkill.value) return
|
||||
loading.value = true
|
||||
@ -223,6 +318,7 @@ const reloadTree = async () => {
|
||||
|
||||
const selectSkill = async (record) => {
|
||||
currentSkill.value = record
|
||||
syncDependencyFormFromSkill(record)
|
||||
resetFileState()
|
||||
await reloadTree()
|
||||
}
|
||||
@ -400,6 +496,29 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveDependencies = async () => {
|
||||
if (!currentSkill.value) return
|
||||
savingDependencies.value = true
|
||||
try {
|
||||
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
|
||||
tool_dependencies: dependencyForm.tool_dependencies,
|
||||
mcp_dependencies: dependencyForm.mcp_dependencies,
|
||||
skill_dependencies: dependencyForm.skill_dependencies
|
||||
})
|
||||
const updated = result?.data || null
|
||||
if (updated) {
|
||||
currentSkill.value = updated
|
||||
syncDependencyFormFromSkill(updated)
|
||||
}
|
||||
await fetchSkills()
|
||||
message.success('依赖保存成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '依赖保存失败')
|
||||
} finally {
|
||||
savingDependencies.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSkills()
|
||||
})
|
||||
@ -463,18 +582,46 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.slug {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.dependency-summary {
|
||||
color: var(--gray-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dependency-panel {
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dependency-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dependency-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dependency-form :deep(.ant-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 35%) 1fr;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user