refactor: 会话级 skill resolver 与 skills 加载链路重构

This commit is contained in:
肖泽涛 2026-02-26 18:31:55 +08:00
parent c1afed8d3d
commit 326f87d67f
17 changed files with 653 additions and 337 deletions

View File

@ -35,6 +35,8 @@ Skills 管理模块用于集中维护可供 Agent 只读引用的技能包。
## Agent 运行时行为 ## Agent 运行时行为
1. `context.skills` 用于配置技能 slug 列表。 1. `context.skills` 用于配置技能 slug 列表。
2. 运行时仅暴露选中 skills 到 `/skills/<slug>/...` 2. 运行时按会话构建 `SkillResolver` 快照(同一会话首次构建,后续复用)。
3. `/skills` 路径只读,不允许写入、编辑、上传。 3. 运行时仅暴露快照中的可见 skills 到 `/skills/<slug>/...`
4. 变更在下一次对话请求生效。 4. `/skills` 路径只读,不允许写入、编辑、上传。
5. 同会话内若 `context.skills` 变化会触发快照重建。
6. 后台修改 skills 内容后,已有会话不会自动刷新,需新会话或调整 `context.skills` 才生效。

View File

@ -75,7 +75,7 @@ async def get_default_agent(current_user: User = Depends(get_required_user)):
default_agent_id = conf.default_agent_id default_agent_id = conf.default_agent_id
# 如果没有设置默认智能体,尝试获取第一个可用的智能体 # 如果没有设置默认智能体,尝试获取第一个可用的智能体
if not default_agent_id: if not default_agent_id:
agents = await agent_manager.get_agents_info() agents = await agent_manager.get_agents_info(include_configurable_items=False)
if agents: if agents:
default_agent_id = agents[0].get("id", "") default_agent_id = agents[0].get("id", "")
@ -94,7 +94,7 @@ async def set_default_agent(request_data: dict = Body(...), current_user=Depends
raise HTTPException(status_code=422, detail="缺少必需的 agent_id 字段") raise HTTPException(status_code=422, detail="缺少必需的 agent_id 字段")
# 验证智能体是否存在 # 验证智能体是否存在
agents = await agent_manager.get_agents_info() agents = await agent_manager.get_agents_info(include_configurable_items=False)
agent_ids = [agent.get("id", "") for agent in agents] agent_ids = [agent.get("id", "") for agent in agents]
if agent_id not in agent_ids: if agent_id not in agent_ids:
@ -137,7 +137,7 @@ async def call(query: str = Body(...), meta: dict = Body(None), current_user: Us
@chat.get("/agent") @chat.get("/agent")
async def get_agent(current_user: User = Depends(get_required_user)): async def get_agent(current_user: User = Depends(get_required_user)):
"""获取所有可用智能体的基本信息(需要登录)""" """获取所有可用智能体的基本信息(需要登录)"""
agents_info = await agent_manager.get_agents_info() agents_info = await agent_manager.get_agents_info(include_configurable_items=False)
# Return agents with basic information (without configurable_items for performance) # Return agents with basic information (without configurable_items for performance)
agents = [ agents = [

View File

@ -76,10 +76,11 @@ async def list_skills_route(
@skills.get("/dependency-options") @skills.get("/dependency-options")
async def get_skill_dependency_options_route( async def get_skill_dependency_options_route(
_current_user: User = Depends(get_superadmin_user), _current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
): ):
"""获取 skill 依赖项可选列表(仅超级管理员)。""" """获取 skill 依赖项可选列表(仅超级管理员)。"""
try: try:
return {"success": True, "data": get_skill_dependency_options()} return {"success": True, "data": await get_skill_dependency_options(db)}
except Exception as e: except Exception as e:
logger.error(f"Failed to get skill dependency options: {e}") logger.error(f"Failed to get skill dependency options: {e}")
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败") raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")

View File

@ -4,7 +4,7 @@ from fastapi import FastAPI
from src.services.task_service import tasker from src.services.task_service import tasker
from src.services.mcp_service import init_mcp_servers from src.services.mcp_service import init_mcp_servers
from src.services.skill_service import init_skills_cache from src.services.run_queue_service import close_queue_clients, get_redis_client
from src.storage.postgres.manager import pg_manager from src.storage.postgres.manager import pg_manager
from src.knowledge import knowledge_base from src.knowledge import knowledge_base
from src.utils import logger from src.utils import logger
@ -28,12 +28,6 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize MCP servers during startup: {e}") logger.error(f"Failed to initialize MCP servers during startup: {e}")
# 初始化 Skills 缓存
try:
await init_skills_cache()
except Exception as e:
logger.error(f"Failed to initialize skills cache during startup: {e}")
# 初始化知识库管理器 # 初始化知识库管理器
try: try:
await knowledge_base.initialize() await knowledge_base.initialize()

View File

@ -39,9 +39,9 @@ class AgentManager(metaclass=SingletonMeta):
for agent_id in self._classes.keys(): for agent_id in self._classes.keys():
self.get_agent(agent_id, reload=True) self.get_agent(agent_id, reload=True)
async def get_agents_info(self): async def get_agents_info(self, include_configurable_items: bool = True):
agents = self.get_agents() agents = self.get_agents()
return await asyncio.gather(*[a.get_info() for a in agents]) return await asyncio.gather(*[a.get_info(include_configurable_items=include_configurable_items) for a in agents])
def auto_discover_agents(self): def auto_discover_agents(self):
"""自动发现并注册 src/agents/ 下的所有智能体。 """自动发现并注册 src/agents/ 下的所有智能体。

View File

@ -6,7 +6,8 @@ from typing import Any
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileUploadResponse, WriteResult from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileUploadResponse, WriteResult
from src.services.skill_service import get_expanded_visible_skill_slugs, get_skills_root_dir, is_valid_skill_slug from src.services.skill_resolver import normalize_selected_skills
from src.services.skill_service import get_skills_root_dir, is_valid_skill_slug
class SelectedSkillsReadonlyBackend(FilesystemBackend): class SelectedSkillsReadonlyBackend(FilesystemBackend):
@ -121,11 +122,23 @@ class SelectedSkillsReadonlyBackend(FilesystemBackend):
def create_agent_composite_backend(runtime) -> CompositeBackend: def create_agent_composite_backend(runtime) -> CompositeBackend:
"""为 agent 构建 backend默认 StateBackend + /skills 路由只读 backend。""" """为 agent 构建 backend默认 StateBackend + /skills 路由只读 backend。"""
selected_skills = getattr(runtime.context, "skills", None) visible_skills = _get_visible_skills_from_runtime(runtime)
visible_skills = get_expanded_visible_skill_slugs(selected_skills or [])
return CompositeBackend( return CompositeBackend(
default=StateBackend(runtime), default=StateBackend(runtime),
routes={ routes={
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills), "/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
}, },
) )
def _get_visible_skills_from_runtime(runtime) -> list[str]:
state = getattr(runtime, "state", None)
if isinstance(state, dict):
snapshot = state.get("skill_session_snapshot")
if isinstance(snapshot, dict):
visible = snapshot.get("visible_skills")
if isinstance(visible, list):
return [slug for slug in visible if isinstance(slug, str) and is_valid_skill_slug(slug)]
selected = getattr(runtime.context, "skills", None) or []
return normalize_selected_skills(selected)

View File

@ -12,6 +12,7 @@ from langgraph.graph.state import CompiledStateGraph
from src import config as sys_config from src import config as sys_config
from src.agents.common.context import BaseContext from src.agents.common.context import BaseContext
from src.services.skill_resolver import get_skill_options_from_db
from src.utils import logger from src.utils import logger
@ -43,9 +44,15 @@ class BaseAgent:
"""Get the agent's class name.""" """Get the agent's class name."""
return self.__class__.__name__ return self.__class__.__name__
async def get_info(self): async def get_info(self, include_configurable_items: bool = True):
# Load metadata from file # Load metadata from file
metadata = self.load_metadata() metadata = self.load_metadata()
configurable_items = {}
if include_configurable_items:
configurable_items = self.context_schema.get_configurable_items()
if "skills" in configurable_items:
configurable_items["skills"] = dict(configurable_items["skills"])
configurable_items["skills"]["options"] = await get_skill_options_from_db()
# Merge metadata with class attributes, metadata takes precedence # Merge metadata with class attributes, metadata takes precedence
return { return {
@ -53,7 +60,7 @@ class BaseAgent:
"name": metadata.get("name", getattr(self, "name", "Unknown")), "name": metadata.get("name", getattr(self, "name", "Unknown")),
"description": metadata.get("description", getattr(self, "description", "Unknown")), "description": metadata.get("description", getattr(self, "description", "Unknown")),
"examples": metadata.get("examples", []), "examples": metadata.get("examples", []),
"configurable_items": self.context_schema.get_configurable_items(), "configurable_items": configurable_items,
"has_checkpointer": await self.check_checkpointer(), "has_checkpointer": await self.check_checkpointer(),
"capabilities": getattr(self, "capabilities", []), # 智能体能力列表 "capabilities": getattr(self, "capabilities", []), # 智能体能力列表
} }

View File

@ -10,7 +10,6 @@ import yaml
from src import config as sys_config from src import config as sys_config
from src.services.mcp_service import get_mcp_server_names from src.services.mcp_service import get_mcp_server_names
from src.services.skill_service import get_skill_options
from src.utils import logger from src.utils import logger
from .tools import gen_tool_info, get_buildin_tools from .tools import gen_tool_info, get_buildin_tools
@ -96,7 +95,7 @@ class BaseContext:
default_factory=list, default_factory=list,
metadata={ metadata={
"name": "Skills", "name": "Skills",
"options": lambda: get_skill_options(), "options": [],
"description": "可选技能列表(由超级管理员维护)。运行时仅挂载并只读暴露选中的 skills。", "description": "可选技能列表(由超级管理员维护)。运行时仅挂载并只读暴露选中的 skills。",
"type": "list", "type": "list",
}, },

View File

@ -14,11 +14,15 @@ from langgraph.types import Command
from src.agents.common import load_chat_model from src.agents.common import load_chat_model
from src.agents.common.tools import get_buildin_tools, get_kb_based_tools from src.agents.common.tools import get_buildin_tools, get_kb_based_tools
from src.services.mcp_service import get_enabled_mcp_tools from src.services.mcp_service import get_enabled_mcp_tools
from src.services.skill_service import ( from src.services.skill_resolver import (
get_dependency_bundle_for_activated_skills, SkillSessionSnapshot,
get_skill_prompt_metadata_by_slugs, build_dependency_bundle,
is_valid_skill_slug, collect_prompt_metadata,
is_snapshot_match_selected_skills,
normalize_selected_skills,
resolve_session_snapshot,
) )
from src.services.skill_service import is_valid_skill_slug
from src.utils.datetime_utils import shanghai_now from src.utils.datetime_utils import shanghai_now
from src.utils.logging_config import logger from src.utils.logging_config import logger
@ -40,6 +44,7 @@ def _activated_skills_reducer(left: list[str] | None, right: list[str] | None) -
class RuntimeConfigState(AgentState): class RuntimeConfigState(AgentState):
activated_skills: NotRequired[Annotated[list[str], _activated_skills_reducer]] activated_skills: NotRequired[Annotated[list[str], _activated_skills_reducer]]
skill_session_snapshot: NotRequired[SkillSessionSnapshot]
class RuntimeConfigMiddleware(AgentMiddleware): class RuntimeConfigMiddleware(AgentMiddleware):
@ -122,6 +127,7 @@ class RuntimeConfigMiddleware(AgentMiddleware):
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse] self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse: ) -> ModelResponse:
runtime_context = request.runtime.context runtime_context = request.runtime.context
snapshot, request = await self._ensure_skill_snapshot(request)
overrides: dict[str, Any] = {} overrides: dict[str, Any] = {}
# 1. 模型覆盖(可选) # 1. 模型覆盖(可选)
@ -131,10 +137,11 @@ class RuntimeConfigMiddleware(AgentMiddleware):
# 2. 工具覆盖(可选) # 2. 工具覆盖(可选)
if self.enable_tools_override: if self.enable_tools_override:
activated_skills = request.state.get("activated_skills", []) if request.state else [] state = request.state if isinstance(request.state, dict) else {}
activated_skills = state.get("activated_skills", [])
if not isinstance(activated_skills, list): if not isinstance(activated_skills, list):
activated_skills = [] activated_skills = []
deps_bundle = get_dependency_bundle_for_activated_skills(activated_skills) deps_bundle = build_dependency_bundle(snapshot, activated_skills)
enabled_tools = await self.get_tools_from_context( enabled_tools = await self.get_tools_from_context(
runtime_context, runtime_context,
extra_tool_names=deps_bundle["tools"], extra_tool_names=deps_bundle["tools"],
@ -161,7 +168,10 @@ class RuntimeConfigMiddleware(AgentMiddleware):
configured_skills = getattr(runtime_context, self.skills_context_name, None) or [] configured_skills = getattr(runtime_context, self.skills_context_name, None) or []
if self.enable_skills_prompt_override and configured_skills: if self.enable_skills_prompt_override and configured_skills:
if self._supports_skill_prompt(request): if self._supports_skill_prompt(request):
skills_meta = get_skill_prompt_metadata_by_slugs(configured_skills) skills_for_prompt = configured_skills
if snapshot and isinstance(snapshot.get("visible_skills"), list):
skills_for_prompt = snapshot.get("visible_skills") or []
skills_meta = collect_prompt_metadata(snapshot, skills_for_prompt)
skills_section = self._build_skills_section(skills_meta) skills_section = self._build_skills_section(skills_meta)
merged_system_prompt = f"{merged_system_prompt}\n\n{skills_section}" merged_system_prompt = f"{merged_system_prompt}\n\n{skills_section}"
else: else:
@ -255,6 +265,9 @@ class RuntimeConfigMiddleware(AgentMiddleware):
slug = self._extract_skill_slug_from_skill_md_path(file_path) slug = self._extract_skill_slug_from_skill_md_path(file_path)
if not slug: if not slug:
return result return result
if not self._is_visible_skill_slug(request, slug):
logger.warning(f"RuntimeConfigMiddleware: deny skill activation for invisible slug: {slug}")
return result
logger.debug(f"RuntimeConfigMiddleware: activated skill by read_file: {slug}") logger.debug(f"RuntimeConfigMiddleware: activated skill by read_file: {slug}")
return self._merge_activated_skill_update(result, slug) return self._merge_activated_skill_update(result, slug)
@ -272,9 +285,36 @@ class RuntimeConfigMiddleware(AgentMiddleware):
slug = self._extract_skill_slug_from_skill_md_path(file_path) slug = self._extract_skill_slug_from_skill_md_path(file_path)
if not slug: if not slug:
return result return result
if not self._is_visible_skill_slug(request, slug):
logger.warning(f"RuntimeConfigMiddleware: deny skill activation for invisible slug: {slug}")
return result
logger.debug(f"RuntimeConfigMiddleware: activated skill by read_file: {slug}") logger.debug(f"RuntimeConfigMiddleware: activated skill by read_file: {slug}")
return self._merge_activated_skill_update(result, slug) return self._merge_activated_skill_update(result, slug)
async def _ensure_skill_snapshot(self, request: ModelRequest) -> tuple[SkillSessionSnapshot | None, ModelRequest]:
runtime_context = request.runtime.context
configured_skills = getattr(runtime_context, self.skills_context_name, None) or []
normalized_skills = normalize_selected_skills(configured_skills)
state = request.state if isinstance(request.state, dict) else {}
snapshot = self._get_skill_snapshot_from_state(state)
if is_snapshot_match_selected_skills(snapshot, normalized_skills):
return snapshot, request
try:
snapshot = await resolve_session_snapshot(normalized_skills)
except Exception as e:
logger.warning(f"RuntimeConfigMiddleware: failed to resolve skill snapshot, fallback empty: {e}")
snapshot = None
if isinstance(request.state, dict):
if snapshot:
request.state["skill_session_snapshot"] = snapshot
else:
request.state.pop("skill_session_snapshot", None)
return snapshot, request
def _extract_skill_slug_from_skill_md_path(self, file_path: Any) -> str | None: def _extract_skill_slug_from_skill_md_path(self, file_path: Any) -> str | None:
if not isinstance(file_path, str): if not isinstance(file_path, str):
return None return None
@ -304,6 +344,29 @@ class RuntimeConfigMiddleware(AgentMiddleware):
return result return result
def _is_visible_skill_slug(self, request: ToolCallRequest, slug: str) -> bool:
snapshot = self._get_skill_snapshot_from_state(request.state)
if snapshot:
visible_skills = snapshot.get("visible_skills")
if isinstance(visible_skills, list):
return slug in visible_skills
configured_skills = getattr(request.runtime.context, self.skills_context_name, None) or []
normalized = normalize_selected_skills(configured_skills)
return slug in normalized
def _get_skill_snapshot_from_state(self, state: Any) -> SkillSessionSnapshot | None:
if not isinstance(state, dict):
return None
snapshot = state.get("skill_session_snapshot")
if not isinstance(snapshot, dict):
return None
visible_skills = snapshot.get("visible_skills")
selected_skills = snapshot.get("selected_skills")
if not isinstance(visible_skills, list) or not isinstance(selected_skills, list):
return None
return snapshot
def _supports_skill_prompt(self, request: ModelRequest) -> bool: def _supports_skill_prompt(self, request: ModelRequest) -> bool:
"""仅当请求工具中包含 read_file 时,才注入 skills 指引。""" """仅当请求工具中包含 read_file 时,才注入 skills 指引。"""
for tool in request.tools or []: for tool in request.tools or []:

View File

@ -0,0 +1,226 @@
from __future__ import annotations
from typing import TypedDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.repositories.skill_repository import SkillRepository
from src.storage.postgres.manager import pg_manager
from src.storage.postgres.models_business import Skill
from src.utils.logging_config import logger
class SkillPromptMetadata(TypedDict):
name: str
description: str
path: str
class SkillDependencyNode(TypedDict):
tools: list[str]
mcps: list[str]
skills: list[str]
class SkillSessionSnapshot(TypedDict):
selected_skills: list[str]
visible_skills: list[str]
prompt_metadata: dict[str, SkillPromptMetadata]
dependency_map: dict[str, SkillDependencyNode]
def normalize_selected_skills(selected_skills: list[str] | None) -> list[str]:
return _normalize_string_list(selected_skills)
def is_snapshot_match_selected_skills(
snapshot: SkillSessionSnapshot | None,
selected_skills: list[str] | None,
) -> bool:
if not snapshot:
return False
current = snapshot.get("selected_skills")
if not isinstance(current, list):
return False
return current == normalize_selected_skills(selected_skills)
async def resolve_session_snapshot(
selected_skills: list[str] | None,
*,
db: AsyncSession | None = None,
) -> SkillSessionSnapshot:
normalized_selected = normalize_selected_skills(selected_skills)
skills = await _list_skills_from_db(db)
prompt_metadata, dependency_map = _build_maps(skills)
visible_skills = expand_skill_closure(normalized_selected, dependency_map)
return {
"selected_skills": normalized_selected,
"visible_skills": visible_skills,
"prompt_metadata": prompt_metadata,
"dependency_map": dependency_map,
}
def collect_prompt_metadata(
snapshot: SkillSessionSnapshot | None,
slugs: list[str] | None,
) -> list[SkillPromptMetadata]:
if not snapshot or not slugs:
return []
prompt_metadata = snapshot.get("prompt_metadata") or {}
result: list[SkillPromptMetadata] = []
seen: set[str] = set()
for slug in slugs:
if not isinstance(slug, str):
continue
normalized = slug.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
item = prompt_metadata.get(normalized)
if not item:
logger.debug(f"Skill slug not found in session snapshot, skip prompt metadata: {normalized}")
continue
result.append(dict(item))
return result
def build_dependency_bundle(
snapshot: SkillSessionSnapshot | None,
activated_slugs: list[str] | None,
) -> dict[str, list[str]]:
if not snapshot:
return {"tools": [], "mcps": [], "skills": []}
dependency_map = snapshot.get("dependency_map") or {}
closure = expand_skill_closure(activated_slugs or [], dependency_map)
tools: list[str] = []
mcps: list[str] = []
seen_tools: set[str] = set()
seen_mcps: set[str] = set()
for slug in closure:
dep = dependency_map.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 mcp_name in dep.get("mcps", []):
if mcp_name in seen_mcps:
continue
seen_mcps.add(mcp_name)
mcps.append(mcp_name)
return {"tools": tools, "mcps": mcps, "skills": closure}
def expand_skill_closure(
slugs: list[str] | None,
dependency_map: dict[str, SkillDependencyNode],
) -> list[str]:
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 = dependency_map.get(slug)
if not node:
logger.warning(f"Skill dependency target not found in DB snapshot, 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
async def get_skill_options_from_db(
*,
db: AsyncSession | None = None,
) -> list[dict[str, str]]:
items = await _list_skills_from_db(db)
return [
{
"id": item.slug,
"name": item.name,
"description": item.description,
}
for item in items
]
async def get_skill_slug_set_from_db(
*,
db: AsyncSession | None = None,
) -> set[str]:
items = await _list_skills_from_db(db)
return {item.slug for item in items}
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 _build_maps(skills: list[Skill]) -> tuple[dict[str, SkillPromptMetadata], dict[str, SkillDependencyNode]]:
prompt_metadata: dict[str, SkillPromptMetadata] = {}
dependency_map: dict[str, SkillDependencyNode] = {}
for item in skills:
prompt_metadata[item.slug] = {
"name": item.name,
"description": item.description,
"path": f"/skills/{item.slug}/SKILL.md",
}
dependency_map[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 []),
}
return prompt_metadata, dependency_map
async def _list_skills_from_db(db: AsyncSession | None) -> list[Skill]:
if db is not None:
repo = SkillRepository(db)
return await repo.list_all()
try:
async with pg_manager.get_async_session_context() as session:
repo = SkillRepository(session)
return await repo.list_all()
except RuntimeError:
# 在非 FastAPI 生命周期场景(如 worker/脚本)按需初始化
pg_manager.initialize()
async with pg_manager.get_async_session_context() as session:
repo = SkillRepository(session)
return await repo.list_all()

View File

@ -14,9 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import config as sys_config from src import config as sys_config
from src.repositories.skill_repository import SkillRepository from src.repositories.skill_repository import SkillRepository
from src.services.mcp_service import get_mcp_server_names 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.storage.postgres.models_business import Skill
from src.utils.logging_config import logger
SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN
@ -52,11 +50,6 @@ TEXT_FILE_EXTENSIONS = {
".tsx", ".tsx",
} }
_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]: def _normalize_string_list(values: list[str] | None) -> list[str]:
if not values: if not values:
return [] return []
@ -98,146 +91,19 @@ def get_skills_root_dir() -> Path:
return root return root
def get_skill_options() -> list[dict[str, str]]: async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str]]:
"""返回技能选项缓存(用于 BaseContext configurable options""" repo = SkillRepository(db)
return list(_skill_options_cache) items = await repo.list_all()
def get_skill_dependency_options() -> dict[str, list[str]]:
return { return {
"tools": _get_buildin_tool_names(), "tools": _get_buildin_tool_names(),
"mcps": get_mcp_server_names(), "mcps": get_mcp_server_names(),
"skills": [item["id"] for item in _skill_options_cache], "skills": [item.slug for item in items],
} }
def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]:
"""按 slug 顺序返回 skills prompt 元数据(仅缓存,无 IO"""
if not slugs:
return []
result: list[dict[str, str]] = []
seen: set[str] = set()
for slug in slugs:
if slug in seen:
continue
seen.add(slug)
item = _skill_prompt_metadata_cache.get(slug)
if not item:
logger.debug(f"Skill slug not found in cache, skip prompt metadata: {slug}")
continue
result.append(dict(item))
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, _skill_dependency_cache
_skill_options_cache = [
{
"id": item.slug,
"name": item.name,
"description": item.description,
}
for item in items
]
_skill_prompt_metadata_cache = {
item.slug: {
"name": item.name,
"description": item.description,
"path": f"/skills/{item.slug}/SKILL.md",
}
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:
"""启动时加载技能缓存,避免每次构建 configurable_items 触发 DB IO。"""
try:
async with pg_manager.get_async_session_context() as session:
repo = SkillRepository(session)
items = await repo.list_all()
_set_skill_options_cache(items)
logger.info(f"Loaded skills cache with {len(items)} items")
except Exception as e:
logger.warning(f"Failed to initialize skills cache: {e}")
async def list_skills(db: AsyncSession) -> list[Skill]: async def list_skills(db: AsyncSession) -> list[Skill]:
repo = SkillRepository(db) repo = SkillRepository(db)
items = await repo.list_all() return await repo.list_all()
_set_skill_options_cache(items)
return items
def _validate_dependencies( def _validate_dependencies(
@ -246,6 +112,7 @@ def _validate_dependencies(
tool_dependencies: list[str], tool_dependencies: list[str],
mcp_dependencies: list[str], mcp_dependencies: list[str],
skill_dependencies: list[str], skill_dependencies: list[str],
available_skill_slugs: set[str],
) -> tuple[list[str], list[str], list[str]]: ) -> tuple[list[str], list[str], list[str]]:
tools = _normalize_string_list(tool_dependencies) tools = _normalize_string_list(tool_dependencies)
mcps = _normalize_string_list(mcp_dependencies) mcps = _normalize_string_list(mcp_dependencies)
@ -261,8 +128,7 @@ def _validate_dependencies(
if invalid_mcps: if invalid_mcps:
raise ValueError(f"存在无效 MCP 依赖: {', '.join(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_skill_slugs]
invalid_skills = [name for name in skills if name not in available_skills]
if invalid_skills: if invalid_skills:
raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}") raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}")
@ -283,24 +149,23 @@ async def update_skill_dependencies(
) -> Skill: ) -> Skill:
item = await get_skill_or_raise(db, slug) item = await get_skill_or_raise(db, slug)
repo = SkillRepository(db) repo = SkillRepository(db)
# 写操作前先同步一次缓存,确保依赖校验基于最新技能集合。 skill_items = await repo.list_all()
_set_skill_options_cache(await repo.list_all()) available_skill_slugs = {skill.slug for skill in skill_items}
tools, mcps, skills = _validate_dependencies( tools, mcps, skills = _validate_dependencies(
slug=slug, slug=slug,
tool_dependencies=tool_dependencies, tool_dependencies=tool_dependencies,
mcp_dependencies=mcp_dependencies, mcp_dependencies=mcp_dependencies,
skill_dependencies=skill_dependencies, skill_dependencies=skill_dependencies,
available_skill_slugs=available_skill_slugs,
) )
updated = await repo.update_dependencies( return await repo.update_dependencies(
item, item,
tool_dependencies=tools, tool_dependencies=tools,
mcp_dependencies=mcps, mcp_dependencies=mcps,
skill_dependencies=skills, skill_dependencies=skills,
updated_by=updated_by, updated_by=updated_by,
) )
_set_skill_options_cache(await repo.list_all())
return updated
def _validate_skill_name(name: str) -> str: def _validate_skill_name(name: str) -> str:
@ -499,8 +364,6 @@ async def import_skill_zip(
shutil.rmtree(final_dir, ignore_errors=True) shutil.rmtree(final_dir, ignore_errors=True)
raise raise
items = await repo.list_all()
_set_skill_options_cache(items)
return item return item
@ -572,7 +435,6 @@ async def create_skill_node(
if parsed_name is not None and parsed_desc is not None: if parsed_name is not None and parsed_desc is not None:
repo = SkillRepository(db) repo = SkillRepository(db)
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by) await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
_set_skill_options_cache(await repo.list_all())
async def update_skill_file( async def update_skill_file(
@ -603,7 +465,6 @@ async def update_skill_file(
if parsed_name is not None and parsed_desc is not None: if parsed_name is not None and parsed_desc is not None:
repo = SkillRepository(db) repo = SkillRepository(db)
await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by) await repo.update_metadata(item, name=parsed_name, description=parsed_desc, updated_by=updated_by)
_set_skill_options_cache(await repo.list_all())
async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None: async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None:
@ -664,5 +525,3 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None:
if trash_dir and trash_dir.exists(): if trash_dir and trash_dir.exists():
shutil.rmtree(trash_dir, ignore_errors=True) shutil.rmtree(trash_dir, ignore_errors=True)
_set_skill_options_cache(await repo.list_all())

View File

@ -10,7 +10,6 @@ from langgraph.types import Command
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
from src.services import skill_service
@dataclass @dataclass
@ -34,18 +33,40 @@ class _FakeRequest:
) )
@dataclass
class _FakeToolCallRequest:
tool_call: dict[str, Any]
runtime: Any
state: dict[str, Any]
async def _echo_handler(request): async def _echo_handler(request):
return request return request
def _build_request(*, skills: list[str], tools: list[str], system_prompt: str = "你是助手") -> _FakeRequest: def _build_request(*, skills: list[str], tools: list[str], system_prompt: str = "你是助手", state=None) -> _FakeRequest:
context = SimpleNamespace(system_prompt=system_prompt, skills=skills) context = SimpleNamespace(system_prompt=system_prompt, skills=skills, tools=[], knowledges=[], mcps=[])
runtime = SimpleNamespace(context=context) runtime = SimpleNamespace(context=context)
return _FakeRequest( return _FakeRequest(
runtime=runtime, runtime=runtime,
tools=[_FakeTool(name=name) for name in tools], tools=[_FakeTool(name=name) for name in tools],
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]), system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
state={}, state=state or {},
)
def _build_tool_request(*, skills: list[str], visible_skills: list[str], file_path: str) -> _FakeToolCallRequest:
return _FakeToolCallRequest(
tool_call={"name": "read_file", "args": {"file_path": file_path}},
runtime=SimpleNamespace(context=SimpleNamespace(skills=skills)),
state={
"skill_session_snapshot": {
"selected_skills": skills,
"visible_skills": visible_skills,
"prompt_metadata": {},
"dependency_map": {},
}
},
) )
@ -62,24 +83,31 @@ def _build_middleware() -> RuntimeConfigMiddleware:
) )
@dataclass def _build_snapshot(selected: list[str], metadata: dict[str, dict[str, str]] | None = None) -> dict[str, Any]:
class _FakeToolCallRequest: return {
tool_call: dict[str, Any] "selected_skills": selected,
"visible_skills": selected,
"prompt_metadata": metadata or {},
"dependency_map": {},
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_injects_skills_section_when_skills_configured_and_read_file_available(monkeypatch: pytest.MonkeyPatch): async def test_injects_skills_section_when_skills_configured_and_read_file_available(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr( async def fake_resolve(selected):
runtime_middleware, assert selected == ["research-report"]
"get_skill_prompt_metadata_by_slugs", return _build_snapshot(
lambda _slugs: [ ["research-report"],
{ {
"name": "research-report", "research-report": {
"description": "Write structured research reports", "name": "research-report",
"path": "/skills/research-report/SKILL.md", "description": "Write structured research reports",
} "path": "/skills/research-report/SKILL.md",
], }
) },
)
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware() middleware = _build_middleware()
request = _build_request(skills=["research-report"], tools=["read_file"]) request = _build_request(skills=["research-report"], tools=["read_file"])
@ -92,14 +120,11 @@ async def test_injects_skills_section_when_skills_configured_and_read_file_avail
assert "Read `/skills/research-report/SKILL.md` for full instructions" in prompt assert "Read `/skills/research-report/SKILL.md` for full instructions" in prompt
assert "Recognize when a skill applies" in prompt assert "Recognize when a skill applies" in prompt
assert "当前时间:" in prompt assert "当前时间:" in prompt
assert "skill_session_snapshot" in result.state
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_skills_section_when_context_skills_empty(monkeypatch: pytest.MonkeyPatch): async def test_skips_skills_section_when_context_skills_empty():
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() middleware = _build_middleware()
request = _build_request(skills=[], tools=["read_file"]) request = _build_request(skills=[], tools=["read_file"])
@ -117,11 +142,20 @@ async def test_skips_skills_section_without_read_file_and_logs_warning(monkeypat
warning=lambda message: warnings.append(message), warning=lambda message: warnings.append(message),
) )
monkeypatch.setattr(runtime_middleware, "logger", fake_logger) monkeypatch.setattr(runtime_middleware, "logger", fake_logger)
monkeypatch.setattr(
runtime_middleware, async def fake_resolve(_selected):
"get_skill_prompt_metadata_by_slugs", return _build_snapshot(
lambda _slugs: (_ for _ in ()).throw(AssertionError("should not query metadata without read_file")), ["research-report"],
) {
"research-report": {
"name": "research-report",
"description": "Write structured research reports",
"path": "/skills/research-report/SKILL.md",
}
},
)
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware() middleware = _build_middleware()
request = _build_request(skills=["research-report"], tools=["write_file"]) request = _build_request(skills=["research-report"], tools=["write_file"])
@ -134,22 +168,16 @@ async def test_skips_skills_section_without_read_file_and_logs_warning(monkeypat
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped(monkeypatch: pytest.MonkeyPatch): async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr( async def fake_resolve(_selected):
skill_service, return _build_snapshot(
"_skill_prompt_metadata_cache", ["beta", "missing", "alpha", "beta"],
{ {
"beta": { "beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"},
"name": "beta", "alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"},
"description": "beta skill",
"path": "/skills/beta/SKILL.md",
}, },
"alpha": { )
"name": "alpha",
"description": "alpha skill", monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
"path": "/skills/alpha/SKILL.md",
},
},
)
middleware = _build_middleware() middleware = _build_middleware()
request = _build_request(skills=["beta", "missing", "alpha", "beta"], tools=["read_file"]) request = _build_request(skills=["beta", "missing", "alpha", "beta"], tools=["read_file"])
@ -168,11 +196,10 @@ async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_awrap_tool_call_activates_skill_when_read_skill_md(): async def test_awrap_tool_call_activates_skill_when_read_skill_md():
middleware = _build_middleware() middleware = _build_middleware()
request = _FakeToolCallRequest( request = _build_tool_request(
tool_call={ skills=["research-report"],
"name": "read_file", visible_skills=["research-report"],
"args": {"file_path": "/skills/research-report/SKILL.md"}, file_path="/skills/research-report/SKILL.md",
}
) )
async def _handler(_request): async def _handler(_request):
@ -187,11 +214,10 @@ async def test_awrap_tool_call_activates_skill_when_read_skill_md():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_awrap_tool_call_skips_invalid_skill_slug_path(): async def test_awrap_tool_call_skips_invalid_skill_slug_path():
middleware = _build_middleware() middleware = _build_middleware()
request = _FakeToolCallRequest( request = _build_tool_request(
tool_call={ skills=["research-report"],
"name": "read_file", visible_skills=["research-report"],
"args": {"file_path": "/skills/../SKILL.md"}, file_path="/skills/../SKILL.md",
}
) )
async def _handler(_request): async def _handler(_request):
@ -204,11 +230,10 @@ async def test_awrap_tool_call_skips_invalid_skill_slug_path():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_awrap_tool_call_merges_with_existing_command_update(): async def test_awrap_tool_call_merges_with_existing_command_update():
middleware = _build_middleware() middleware = _build_middleware()
request = _FakeToolCallRequest( request = _build_tool_request(
tool_call={ skills=["research-report"],
"name": "read_file", visible_skills=["research-report"],
"args": {"file_path": "/skills/research-report/SKILL.md"}, file_path="/skills/research-report/SKILL.md",
}
) )
async def _handler(_request): async def _handler(_request):
@ -219,6 +244,22 @@ async def test_awrap_tool_call_merges_with_existing_command_update():
assert result.update["activated_skills"] == ["a", "research-report"] assert result.update["activated_skills"] == ["a", "research-report"]
@pytest.mark.asyncio
async def test_awrap_tool_call_denies_invisible_skill():
middleware = _build_middleware()
request = _build_tool_request(
skills=["research-report"],
visible_skills=["alpha"],
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, ToolMessage)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch): async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr( monkeypatch.setattr(
@ -229,8 +270,8 @@ async def test_model_call_injects_dependency_tools_and_mcps_after_activation(mon
monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: []) monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: [])
monkeypatch.setattr( monkeypatch.setattr(
runtime_middleware, runtime_middleware,
"get_dependency_bundle_for_activated_skills", "build_dependency_bundle",
lambda activated: {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": activated}, lambda _snapshot, activated: {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": activated},
) )
async def fake_get_enabled_mcp_tools(server_name: str): async def fake_get_enabled_mcp_tools(server_name: str):
@ -248,17 +289,10 @@ async def test_model_call_injects_dependency_tools_and_mcps_after_activation(mon
enable_skills_prompt_override=False, enable_skills_prompt_override=False,
) )
context = SimpleNamespace(system_prompt="x", skills=[], tools=[], knowledges=[], mcps=[]) request = _build_request(
request = _FakeRequest( skills=[],
runtime=SimpleNamespace(context=context), tools=["calculator", "dep-tool", "mcp_tool", "read_file"],
tools=[ state={"activated_skills": ["alpha"], "skill_session_snapshot": _build_snapshot([])},
_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) result = await middleware.awrap_model_call(request, _echo_handler)
@ -266,3 +300,53 @@ async def test_model_call_injects_dependency_tools_and_mcps_after_activation(mon
assert "dep-tool" in tool_names assert "dep-tool" in tool_names
assert "mcp_tool" in tool_names assert "mcp_tool" in tool_names
assert "calculator" not in tool_names assert "calculator" not in tool_names
@pytest.mark.asyncio
async def test_model_call_reuses_snapshot_until_skills_changed(monkeypatch: pytest.MonkeyPatch):
called = {"count": 0}
async def fake_resolve(selected):
called["count"] += 1
return _build_snapshot(selected)
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware()
req1 = _build_request(skills=["alpha"], tools=["read_file"], state={})
res1 = await middleware.awrap_model_call(req1, _echo_handler)
assert called["count"] == 1
req2 = _build_request(skills=["alpha"], tools=["read_file"], state=res1.state)
await middleware.awrap_model_call(req2, _echo_handler)
assert called["count"] == 1
req3 = _build_request(skills=["beta"], tools=["read_file"], state=res1.state)
await middleware.awrap_model_call(req3, _echo_handler)
assert called["count"] == 2
@pytest.mark.asyncio
async def test_injects_dependency_skills_into_prompt(monkeypatch: pytest.MonkeyPatch):
async def fake_resolve(_selected):
return {
"selected_skills": ["alpha"],
"visible_skills": ["alpha", "beta"],
"prompt_metadata": {
"alpha": {"name": "alpha", "description": "alpha desc", "path": "/skills/alpha/SKILL.md"},
"beta": {"name": "beta", "description": "beta desc", "path": "/skills/beta/SKILL.md"},
},
"dependency_map": {
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
"beta": {"tools": [], "mcps": [], "skills": []},
},
}
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware()
request = _build_request(skills=["alpha"], tools=["read_file"])
result = await middleware.awrap_model_call(request, _echo_handler)
prompt = _extract_appended_prompt(result)
assert "- **alpha**: alpha desc" in prompt
assert "- **beta**: beta desc" in prompt

View File

@ -0,0 +1,85 @@
from __future__ import annotations
import pytest
from src.services import skill_resolver as resolver
from src.storage.postgres.models_business import Skill
def test_expand_skill_closure_and_dependency_bundle():
dependency_map = {
"alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]},
"beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]},
"gamma": {"tools": ["t3"], "mcps": [], "skills": []},
}
snapshot: resolver.SkillSessionSnapshot = {
"selected_skills": ["alpha"],
"visible_skills": ["alpha", "beta", "gamma"],
"prompt_metadata": {},
"dependency_map": dependency_map,
}
closure = resolver.expand_skill_closure(["alpha"], dependency_map)
assert closure == ["alpha", "beta", "gamma"]
bundle = resolver.build_dependency_bundle(snapshot, ["alpha"])
assert bundle["skills"] == ["alpha", "beta", "gamma"]
assert bundle["tools"] == ["t1", "t2", "t3"]
assert bundle["mcps"] == ["m1", "m2"]
def test_expand_skill_closure_cycle():
dependency_map = {
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
"beta": {"tools": [], "mcps": [], "skills": ["alpha"]},
}
assert resolver.expand_skill_closure(["alpha"], dependency_map) == ["alpha", "beta"]
def test_collect_prompt_metadata_order_and_dedup():
snapshot: resolver.SkillSessionSnapshot = {
"selected_skills": ["beta", "alpha"],
"visible_skills": ["beta", "alpha"],
"prompt_metadata": {
"beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"},
"alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"},
},
"dependency_map": {},
}
result = resolver.collect_prompt_metadata(snapshot, ["beta", "missing", "alpha", "beta"])
assert [item["name"] for item in result] == ["beta", "alpha"]
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
@pytest.mark.asyncio
async def test_resolve_session_snapshot_and_selected_change(monkeypatch: pytest.MonkeyPatch):
async def fake_list_skills(_db=None):
return [
Skill(
slug="alpha",
name="alpha",
description="a",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=["beta"],
dir_path="skills/alpha",
),
Skill(
slug="beta",
name="beta",
description="b",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
dir_path="skills/beta",
),
]
monkeypatch.setattr(resolver, "_list_skills_from_db", fake_list_skills)
snapshot = await resolver.resolve_session_snapshot([" alpha ", "alpha"])
assert snapshot["selected_skills"] == ["alpha"]
assert snapshot["visible_skills"] == ["alpha", "beta"]
assert resolver.is_snapshot_match_selected_skills(snapshot, ["alpha"]) is True
assert resolver.is_snapshot_match_selected_skills(snapshot, ["beta"]) is False

View File

@ -100,14 +100,14 @@ def test_update_skill_file_passes_operator(monkeypatch):
def test_dependency_options_route(monkeypatch): def test_dependency_options_route(monkeypatch):
monkeypatch.setattr( async def fake_get_skill_dependency_options(_db):
"server.routers.skill_router.get_skill_dependency_options", return {
lambda: {
"tools": ["calculator"], "tools": ["calculator"],
"mcps": ["mcp-a"], "mcps": ["mcp-a"],
"skills": ["demo"], "skills": ["demo"],
}, }
)
monkeypatch.setattr("server.routers.skill_router.get_skill_dependency_options", fake_get_skill_dependency_options)
app = _build_app(allow_superadmin=True) app = _build_app(allow_superadmin=True)
client = TestClient(app) client = TestClient(app)

View File

@ -43,72 +43,29 @@ def test_validate_skill_slug():
svc.validate_skill_slug("../bad") svc.validate_skill_slug("../bad")
def test_get_skill_prompt_metadata_by_slugs_dedup_and_skip_missing(monkeypatch: pytest.MonkeyPatch): @pytest.mark.asyncio
monkeypatch.setattr( async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
svc,
"_skill_prompt_metadata_cache",
{
"alpha": {"name": "alpha", "description": "a", "path": "/skills/alpha/SKILL.md"},
"beta": {"name": "beta", "description": "b", "path": "/skills/beta/SKILL.md"},
},
)
result = svc.get_skill_prompt_metadata_by_slugs(["beta", "missing", "alpha", "beta"])
assert [item["name"] for item in result] == ["beta", "alpha"]
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_buildin_tool_names", lambda: ["calculator", "search"])
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"]) 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() class FakeRepo:
def __init__(self, _db):
pass
async def list_all(self):
return [
Skill(slug="alpha", name="alpha", description="a", dir_path="skills/alpha"),
Skill(slug="beta", name="beta", description="b", dir_path="skills/beta"),
]
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
result = await svc.get_skill_dependency_options(None)
assert result["tools"] == ["calculator", "search"] assert result["tools"] == ["calculator", "search"]
assert result["mcps"] == ["mcp-a", "mcp-b"] assert result["mcps"] == ["mcp-a", "mcp-b"]
assert result["skills"] == ["alpha", "beta"] 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): def test_resolve_relative_path_blocks_traversal(tmp_path: Path):
skill_dir = tmp_path / "skill" skill_dir = tmp_path / "skill"
skill_dir.mkdir(parents=True, exist_ok=True) skill_dir.mkdir(parents=True, exist_ok=True)
@ -158,9 +115,6 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
self.__class__.created_item = item self.__class__.created_item = item
return item return item
async def list_all(self) -> list[Skill]:
return [self.__class__.created_item] if self.__class__.created_item else []
monkeypatch.setattr(svc, "SkillRepository", FakeRepo) monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
zip_bytes = _build_zip( zip_bytes = _build_zip(
@ -230,9 +184,6 @@ async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytes
updates["updated_by"] = updated_by updates["updated_by"] = updated_by
return item return item
async def list_all(self) -> list[Skill]:
return [item]
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise) monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo) monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
@ -271,14 +222,6 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
) )
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"]) monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"]) 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): async def fake_get_skill_or_raise(_db, slug: str):
assert slug == "alpha" assert slug == "alpha"

View File

@ -49,11 +49,17 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo
def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch): def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
_prepare_skills_dir(tmp_path) _prepare_skills_dir(tmp_path)
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: 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( runtime = SimpleNamespace(
context=SimpleNamespace(skills=["alpha"]), context=SimpleNamespace(skills=["alpha"]),
state={}, state={
"skill_session_snapshot": {
"selected_skills": ["alpha"],
"visible_skills": ["alpha", "beta"],
"prompt_metadata": {},
"dependency_map": {},
}
},
) )
composite = skills_backend.create_agent_composite_backend(runtime) composite = skills_backend.create_agent_composite_backend(runtime)
@ -67,3 +73,17 @@ def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
denied = composite.write("/skills/alpha/new.md", "x") denied = composite.write("/skills/alpha/new.md", "x")
assert denied.error and "read-only" in denied.error assert denied.error and "read-only" in denied.error
def test_composite_backend_fallbacks_to_context_skills_when_snapshot_missing(tmp_path, monkeypatch):
_prepare_skills_dir(tmp_path)
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
runtime = SimpleNamespace(
context=SimpleNamespace(skills=["alpha"]),
state={},
)
composite = skills_backend.create_agent_composite_backend(runtime)
skills_root = composite.ls_info("/skills/")
skill_paths = sorted(entry.get("path") for entry in skills_root)
assert skill_paths == ["/skills/alpha/"]

View File

@ -372,6 +372,7 @@ import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useDatabaseStore } from '@/stores/database' import { useDatabaseStore } from '@/stores/database'
import { skillApi } from '@/apis/skill_api'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
// Props // Props
@ -399,6 +400,7 @@ watch(
async (val) => { async (val) => {
if (val) { if (val) {
databaseStore.loadDatabases().catch(() => {}) databaseStore.loadDatabases().catch(() => {})
loadLiveSkillOptions().catch(() => {})
if (selectedAgentId.value) { if (selectedAgentId.value) {
try { try {
await agentStore.fetchAgentDetail(selectedAgentId.value, true) await agentStore.fetchAgentDetail(selectedAgentId.value, true)
@ -430,6 +432,7 @@ const tempSelectedValues = ref([])
const selectionSearchText = ref('') const selectionSearchText = ref('')
const systemPromptEditMode = ref(false) const systemPromptEditMode = ref(false)
const activeTab = ref('basic') const activeTab = ref('basic')
const liveSkillOptions = ref([])
const isEmptyConfig = computed(() => { const isEmptyConfig = computed(() => {
return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0 return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0
@ -470,6 +473,24 @@ const segmentedOptions = computed(() => {
return options return options
}) })
const loadLiveSkillOptions = async () => {
if (!userStore.isAdmin) {
liveSkillOptions.value = []
return
}
try {
const result = await skillApi.listSkills()
const rows = result?.data || []
liveSkillOptions.value = rows.map((item) => ({
id: item.slug,
name: item.slug,
description: item.description || ''
}))
} catch (error) {
console.warn('加载实时 Skills 列表失败:', error)
}
}
// //
const getConfigOptions = (value) => { const getConfigOptions = (value) => {
if (value?.template_metadata?.kind === 'tools') { if (value?.template_metadata?.kind === 'tools') {
@ -478,6 +499,9 @@ const getConfigOptions = (value) => {
if (value?.template_metadata?.kind === 'knowledges') { if (value?.template_metadata?.kind === 'knowledges') {
return databaseStore.databases || [] return databaseStore.databases || []
} }
if (value?.template_metadata?.kind === 'skills') {
return liveSkillOptions.value.length > 0 ? liveSkillOptions.value : value?.options || []
}
return value?.options || [] return value?.options || []
} }
@ -636,12 +660,8 @@ const openSelectionModal = async (key) => {
console.error('加载知识库列表失败:', error) console.error('加载知识库列表失败:', error)
} }
} }
if (configurableItems.value[key]?.template_metadata?.kind === 'skills' && selectedAgentId.value) { if (configurableItems.value[key]?.template_metadata?.kind === 'skills') {
try { await loadLiveSkillOptions()
await agentStore.fetchAgentDetail(selectedAgentId.value, true)
} catch (error) {
console.error('刷新 Skills 列表失败:', error)
}
} }
const currentValues = agentConfig.value[key] || [] const currentValues = agentConfig.value[key] || []
tempSelectedValues.value = [...currentValues] tempSelectedValues.value = [...currentValues]