diff --git a/server/routers/skill_router.py b/server/routers/skill_router.py index d41a187f..ac7a3c3b 100644 --- a/server/routers/skill_router.py +++ b/server/routers/skill_router.py @@ -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, diff --git a/server/utils/lifespan.py b/server/utils/lifespan.py index 46c098aa..90d4b01a 100644 --- a/server/utils/lifespan.py +++ b/server/utils/lifespan.py @@ -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}") diff --git a/src/agents/common/backends/skills_backend.py b/src/agents/common/backends/skills_backend.py index 8ca8b0fa..a052b01c 100644 --- a/src/agents/common/backends/skills_backend.py +++ b/src/agents/common/backends/skills_backend.py @@ -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), }, ) diff --git a/src/repositories/skill_repository.py b/src/repositories/skill_repository.py index 16dfef95..745e5c46 100644 --- a/src/repositories/skill_repository.py +++ b/src/repositories/skill_repository.py @@ -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, diff --git a/src/services/skill_service.py b/src/services/skill_service.py index 03c51379..2a0cd2be 100644 --- a/src/services/skill_service.py +++ b/src/services/skill_service.py @@ -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, ) diff --git a/src/storage/postgres/manager.py b/src/storage/postgres/manager.py index a4fa9254..390f00f4 100644 --- a/src/storage/postgres/manager.py +++ b/src/storage/postgres/manager.py @@ -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 数据库""" diff --git a/src/storage/postgres/models_business.py b/src/storage/postgres/models_business.py index 29f5c4d0..a5e7cabd 100644 --- a/src/storage/postgres/models_business.py +++ b/src/storage/postgres/models_business.py @@ -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, diff --git a/test/test_runtime_config_middleware_skills.py b/test/test_runtime_config_middleware_skills.py index 84149076..4db01278 100644 --- a/test/test_runtime_config_middleware_skills.py +++ b/test/test_runtime_config_middleware_skills.py @@ -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 diff --git a/test/test_skill_router.py b/test/test_skill_router.py index 4268a831..72ab3bbe 100644 --- a/test/test_skill_router.py +++ b/test/test_skill_router.py @@ -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" diff --git a/test/test_skill_service.py b/test/test_skill_service.py index 9486e761..7fb7298b 100644 --- a/test/test_skill_service.py +++ b/test/test_skill_service.py @@ -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"] diff --git a/test/test_skills_backend.py b/test/test_skills_backend.py index 6e017c5f..b26f743e 100644 --- a/test/test_skills_backend.py +++ b/test/test_skills_backend.py @@ -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 diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index 77aceaa8..f0bbfbc9 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -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 diff --git a/web/src/components/SkillsManagerComponent.vue b/web/src/components/SkillsManagerComponent.vue index b696c2ed..b4ebd9c1 100644 --- a/web/src/components/SkillsManagerComponent.vue +++ b/web/src/components/SkillsManagerComponent.vue @@ -41,6 +41,11 @@