feat: 技能六级可插拔隔离(global/org/pipeline/role/project/user)+slash命令注册表

This commit is contained in:
yumoqing 2026-08-15 09:38:31 +08:00
parent c3fcc35e08
commit 82c2724789
4 changed files with 243 additions and 81 deletions

View File

@ -36,6 +36,14 @@ from .ability import (
get_ability_tools,
get_ability_handler,
)
from .slash import (
SlashCommand,
register_slash_command,
get_slash_commands,
resolve_slash_command,
parse_slash_args,
list_commands,
)
from .tool_registry import (
ToolRegistry,
get_tool_registry,

View File

@ -49,12 +49,15 @@ class MemoryConfig:
@dataclass
class SkillConfig:
"""技能配置 — 三级隔离:全局 + 组织 + 用户"""
"""技能配置 — 六级隔离global→org→pipeline→role→project→user"""
enabled: bool = True
base_dir: str = "skills" # 技能根目录,其下为 global/orgs/users/
base_dir: str = "skills" # 技能根目录,其下为 global/pipelines/projects/orgs/users/
max_skills_per_turn: int = 5
enable_global: bool = True # 启用全局技能
enable_org: bool = True # 启用组织技能
enable_pipeline: bool = True # 启用产线技能
enable_role: bool = True # 启用角色技能
enable_project: bool = True # 启用项目/会话技能
enable_user: bool = True # 启用用户技能
@ -117,6 +120,9 @@ class AgentConfig:
"base_dir": self.skills.base_dir,
"enable_global": self.skills.enable_global,
"enable_org": self.skills.enable_org,
"enable_pipeline": self.skills.enable_pipeline,
"enable_role": self.skills.enable_role,
"enable_project": self.skills.enable_project,
"enable_user": self.skills.enable_user,
},
"tools": [
@ -165,6 +171,9 @@ class AgentConfig:
max_skills_per_turn=sk.get("max_skills_per_turn", 5),
enable_global=sk.get("enable_global", True),
enable_org=sk.get("enable_org", True),
enable_pipeline=sk.get("enable_pipeline", True),
enable_role=sk.get("enable_role", True),
enable_project=sk.get("enable_project", True),
enable_user=sk.get("enable_user", True),
),
tools=[ToolDefinition(**t) if isinstance(t, dict) else t for t in data.get("tools", [])],

View File

@ -1,33 +1,55 @@
"""
pipeline-core: Skill Loader v2 三级技能隔离
pipeline-core: Skill Loader 六级可插拔技能隔离
技能目录结构每级可独立存在同名高优先级覆盖低优先级
技能目录结构
skills/
global/ 全局技能所有人可见
skill-name/
SKILL.md
orgs/ 组织技能
{org_id}/
skill-name/
SKILL.md
users/ 用户个人技能
{user_id}/
skill-name/
SKILL.md
global/ 通用技能core 内核所有产线所有角色
skill-name/SKILL.md
pipelines/ 产线技能
{pipeline_id}/
common/ 产线通用技能该产线所有角色可见
skill-name/SKILL.md
roles/ 角色技能产线内角色专属
{role}/
skill-name/SKILL.md
projects/ 项目/会话技能临时项目特定
{project_id}/
skill-name/SKILL.md
orgs/{org_id}/ 组织技能
users/{user_id}/ 用户个人技能
加载优先级用户 > 组织 > 全局
同名技能用户级覆盖组织级覆盖全局级
加载优先级 同名后者覆盖前者
global org pipeline(common) role project user
"""
import json
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional
logger = logging.getLogger("pipeline.skill_loader")
# scope 优先级(数值越大优先级越高)
SCOPE_PRIORITY = {
"global": 0,
"org": 1,
"pipeline": 2,
"role": 3,
"project": 4,
"user": 5,
}
SCOPE_TAG = {
"global": "通用",
"org": "组织",
"pipeline": "产线",
"role": "角色",
"project": "项目",
"user": "个人",
}
# ── 技能数据模型 ──
@dataclass
@ -37,15 +59,14 @@ class Skill:
path: str # SKILL.md 绝对路径
description: str = ""
content: str = ""
scope: str = "global" # global / org / user
scope_id: str = "" # org_id user_id
scope: str = "global" # global/org/pipeline/role/project/user
scope_id: str = "" # org_id / pipeline_id / pipeline_id:role / project_id / user_id
trigger_keywords: List[str] = field(default_factory=list)
version: str = "1.0.0"
@classmethod
def from_file(cls, filepath: str, scope: str = "global",
scope_id: str = "") -> "Skill":
"""从 SKILL.md 文件加载技能"""
name = os.path.basename(os.path.dirname(filepath))
content = ""
description = ""
@ -59,13 +80,11 @@ class Skill:
logger.warning(f"Skill read failed: {filepath} err={e}")
return cls(name=name, path=filepath, scope=scope, scope_id=scope_id)
# 解析 YAML frontmatter
fm = _parse_frontmatter(content)
if fm:
description = fm.get("description", description)
trigger_keywords = fm.get("trigger_keywords", trigger_keywords)
version = fm.get("version", version)
# frontmatter 中的 scope 优先
scope = fm.get("scope", scope)
if not description:
@ -82,8 +101,7 @@ class Skill:
)
def to_prompt_block(self) -> str:
"""生成注入到 system prompt 的技能文本块"""
scope_tag = {"global": "全局", "org": "组织", "user": "个人"}.get(self.scope, "")
scope_tag = SCOPE_TAG.get(self.scope, self.scope)
return f"""## [{scope_tag}] {self.name}
{self.description}
@ -92,7 +110,6 @@ class Skill:
def _parse_frontmatter(content: str) -> Optional[dict]:
"""解析 YAML frontmatter"""
if not content.startswith("---"):
return None
end = content.find("---", 3)
@ -113,22 +130,23 @@ def _parse_frontmatter(content: str) -> Optional[dict]:
return result
# ── 级技能加载器 ──
# ── 级技能加载器 ──
class SkillLoader:
"""三级技能加载器global → org → user"""
"""六级技能加载器global → org → pipeline → role → project → user"""
def __init__(self, base_dir: str = ""):
self.base_dir = base_dir
# 三级存储: {scope: {name: Skill}}
self._global: Dict[str, Skill] = {}
self._orgs: Dict[str, Dict[str, Skill]] = {} # {org_id: {name: Skill}}
self._users: Dict[str, Dict[str, Skill]] = {} # {user_id: {name: Skill}}
self._orgs: Dict[str, Dict[str, Skill]] = {} # {org_id: {name: Skill}}
self._pipelines: Dict[str, Dict[str, Skill]] = {} # {pipeline_id: {name: Skill}}
self._roles: Dict[str, Dict[str, Skill]] = {} # {pipeline_id:role: {name: Skill}}
self._projects: Dict[str, Dict[str, Skill]] = {} # {project_id: {name: Skill}}
self._users: Dict[str, Dict[str, Skill]] = {} # {user_id: {name: Skill}}
if base_dir:
self.reload()
def set_base_dir(self, base_dir: str):
"""设置技能根目录并扫描"""
self.base_dir = base_dir
if base_dir:
self.reload()
@ -136,41 +154,74 @@ class SkillLoader:
# ── 目录扫描 ──
def reload(self):
"""重新扫描所有技能目录"""
self._global = {}
self._orgs = {}
self._pipelines = {}
self._roles = {}
self._projects = {}
self._users = {}
if not self.base_dir or not os.path.isdir(self.base_dir):
return
# 全局技能: skills/global/
# 通用: skills/global/
global_dir = os.path.join(self.base_dir, "global")
if os.path.isdir(global_dir):
self._global = self._scan_scope_dir(global_dir, "global")
# 组织技能: skills/orgs/{org_id}/
# 组织: skills/orgs/{org_id}/
orgs_dir = os.path.join(self.base_dir, "orgs")
if os.path.isdir(orgs_dir):
for org_id in os.listdir(orgs_dir):
org_path = os.path.join(orgs_dir, org_id)
if os.path.isdir(org_path):
self._orgs[org_id] = self._scan_scope_dir(org_path, "org", org_id)
p = os.path.join(orgs_dir, org_id)
if os.path.isdir(p):
self._orgs[org_id] = self._scan_scope_dir(p, "org", org_id)
# 用户技能: skills/users/{user_id}/
# 产线: skills/pipelines/{pipeline_id}/common/ + roles/{role}/
pipelines_dir = os.path.join(self.base_dir, "pipelines")
if os.path.isdir(pipelines_dir):
for pid in os.listdir(pipelines_dir):
pdir = os.path.join(pipelines_dir, pid)
if not os.path.isdir(pdir):
continue
# 产线通用技能
common_dir = os.path.join(pdir, "common")
if os.path.isdir(common_dir):
self._pipelines[pid] = self._scan_scope_dir(common_dir, "pipeline", pid)
# 角色技能
roles_dir = os.path.join(pdir, "roles")
if os.path.isdir(roles_dir):
for role in os.listdir(roles_dir):
rdir = os.path.join(roles_dir, role)
if os.path.isdir(rdir):
key = f"{pid}:{role}"
self._roles[key] = self._scan_scope_dir(rdir, "role", key)
# 项目/会话: skills/projects/{project_id}/
projects_dir = os.path.join(self.base_dir, "projects")
if os.path.isdir(projects_dir):
for project_id in os.listdir(projects_dir):
p = os.path.join(projects_dir, project_id)
if os.path.isdir(p):
self._projects[project_id] = self._scan_scope_dir(p, "project", project_id)
# 用户: skills/users/{user_id}/
users_dir = os.path.join(self.base_dir, "users")
if os.path.isdir(users_dir):
for user_id in os.listdir(users_dir):
user_path = os.path.join(users_dir, user_id)
if os.path.isdir(user_path):
self._users[user_id] = self._scan_scope_dir(user_path, "user", user_id)
p = os.path.join(users_dir, user_id)
if os.path.isdir(p):
self._users[user_id] = self._scan_scope_dir(p, "user", user_id)
logger.debug(f"SkillLoader reloaded: global={len(self._global)}, "
f"orgs={len(self._orgs)}, users={len(self._users)}")
f"orgs={sum(len(s) for s in self._orgs.values())}, "
f"pipelines={sum(len(s) for s in self._pipelines.values())}, "
f"roles={sum(len(s) for s in self._roles.values())}, "
f"projects={sum(len(s) for s in self._projects.values())}, "
f"users={sum(len(s) for s in self._users.values())}")
def _scan_scope_dir(self, scope_dir: str, scope: str,
scope_id: str = "") -> Dict[str, Skill]:
"""扫描单个 scope 目录下的所有技能"""
skills = {}
for entry in os.listdir(scope_dir):
entry_path = os.path.join(scope_dir, entry)
@ -180,7 +231,6 @@ class SkillLoader:
if os.path.isfile(skill_file):
skill = Skill.from_file(skill_file, scope, scope_id)
skills[skill.name] = skill
# 也支持子目录嵌套
for sub in os.listdir(entry_path):
sub_path = os.path.join(entry_path, sub)
if os.path.isdir(sub_path):
@ -192,24 +242,40 @@ class SkillLoader:
# ── 查询 ──
def get_merged(self, org_id: str = "", user_id: str = "") -> Dict[str, Skill]:
"""按优先级合并:全局 + 组织 + 用户。同名技能后者覆盖前者。"""
merged = dict(self._global) # 全局为基础
def get_merged(self, pipeline_id: str = "", role: str = "",
project_id: str = "", org_id: str = "",
user_id: str = "") -> Dict[str, Skill]:
"""按优先级合并六级技能。同名技能高优先级覆盖低优先级。
优先级global org pipeline role project user
"""
merged = dict(self._global)
# 组织技能覆盖
if org_id and org_id in self._orgs:
merged.update(self._orgs[org_id])
# 用户技能覆盖(最高优先级)
if pipeline_id and pipeline_id in self._pipelines:
merged.update(self._pipelines[pipeline_id])
if pipeline_id and role:
role_key = f"{pipeline_id}:{role}"
if role_key in self._roles:
merged.update(self._roles[role_key])
if project_id and project_id in self._projects:
merged.update(self._projects[project_id])
if user_id and user_id in self._users:
merged.update(self._users[user_id])
return merged
def get_by_trigger(self, user_input: str, org_id: str = "",
user_id: str = "", max_skills: int = 5) -> List[Skill]:
"""根据用户输入匹配相关技能(基于 trigger_keywords"""
all_skills = list(self.get_merged(org_id, user_id).values())
def get_by_trigger(self, user_input: str, pipeline_id: str = "",
role: str = "", project_id: str = "",
org_id: str = "", user_id: str = "",
max_skills: int = 5) -> List[Skill]:
"""根据用户输入匹配相关技能trigger_keywords + scope 加权)"""
all_skills = list(self.get_merged(pipeline_id, role, project_id, org_id, user_id).values())
scored = []
user_lower = user_input.lower()
for skill in all_skills:
@ -219,45 +285,41 @@ class SkillLoader:
score += 1
if skill.name.lower() in user_lower:
score += 2
# scope 加分:用户 > 组织 > 全局
if skill.scope == "user":
score += 3
elif skill.scope == "org":
score += 2
elif skill.scope == "global":
score += 1
# scope 优先级加权
score += SCOPE_PRIORITY.get(skill.scope, 0)
if score > 0:
scored.append((score, skill))
scored.sort(key=lambda x: x[0], reverse=True)
return [s[1] for s in scored[:max_skills]]
def get_skill_count(self, org_id: str = "", user_id: str = "") -> int:
"""可见技能总数"""
return len(self.get_merged(org_id, user_id))
def get_skill_count(self, pipeline_id: str = "", role: str = "",
project_id: str = "", org_id: str = "",
user_id: str = "") -> int:
return len(self.get_merged(pipeline_id, role, project_id, org_id, user_id))
def list_by_scope(self) -> Dict[str, int]:
"""各 scope 的技能数量"""
return {
"global": len(self._global),
"orgs": sum(len(s) for s in self._orgs.values()),
"pipelines": sum(len(s) for s in self._pipelines.values()),
"roles": sum(len(s) for s in self._roles.values()),
"projects": sum(len(s) for s in self._projects.values()),
"users": sum(len(s) for s in self._users.values()),
}
# ── Prompt 构建 ──
def build_prompt_block(self, org_id: str = "", user_id: str = "",
user_input: str = None, max_skills: int = 5) -> str:
"""构建注入 system prompt 的技能段落。
user_input 时按 trigger 匹配否则取全部受限 max_skills
"""
def build_prompt_block(self, pipeline_id: str = "", role: str = "",
project_id: str = "", org_id: str = "",
user_id: str = "", user_input: Optional[str] = None,
max_skills: int = 5) -> str:
if user_input:
skills = self.get_by_trigger(user_input, org_id, user_id, max_skills)
skills = self.get_by_trigger(user_input, pipeline_id, role,
project_id, org_id, user_id, max_skills)
else:
merged = self.get_merged(org_id, user_id)
# 优先用户级
merged = self.get_merged(pipeline_id, role, project_id, org_id, user_id)
skills = sorted(merged.values(),
key=lambda s: {"user": 0, "org": 1, "global": 2}.get(s.scope, 9))
key=lambda s: SCOPE_PRIORITY.get(s.scope, 9))
skills = skills[:max_skills]
if not skills:
@ -265,14 +327,15 @@ class SkillLoader:
blocks = ["## 可用技能\n"]
for s in skills:
blocks.append(f"- **[{s.scope}] {s.name}**: {s.description[:200]}")
blocks.append(f"- **[{SCOPE_TAG.get(s.scope, s.scope)}] {s.name}**: {s.description[:200]}")
blocks.append("")
return "\n".join(blocks)
def build_full_prompt_block(self, skill_names: List[str],
org_id: str = "", user_id: str = "") -> str:
"""注入指定技能的完整内容"""
merged = self.get_merged(org_id, user_id)
pipeline_id: str = "", role: str = "",
project_id: str = "", org_id: str = "",
user_id: str = "") -> str:
merged = self.get_merged(pipeline_id, role, project_id, org_id, user_id)
blocks = []
for name in skill_names:
if name in merged:
@ -286,7 +349,6 @@ _loader: Optional[SkillLoader] = None
def get_skill_loader(base_dir: str = "") -> SkillLoader:
"""获取全局技能加载器单例。首次调用可传入 base_dir。"""
global _loader
if _loader is None:
_loader = SkillLoader(base_dir)

83
pipeline_core/slash.py Normal file
View File

@ -0,0 +1,83 @@
"""
pipeline-core: SlashCommand 可插拔 slash 命令注册表
slash 命令 = 产线/角色/通用 的快捷指令用户以 "/" 开头输入时直接触发
不进入 LLM 循环类似 Hermes CLI /new /model /tools /skills
命令可插拔通用命令在 core 注册产线命令在能力包sdlc_ability注册
不同产线可有不同的 slash 命名
依赖方向pipeline-core本文件纯定义 + 注册表 pipeline-service注册通用命令
sdlc_ability注册 SDLC 命令
"""
import logging
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
logger = logging.getLogger("pipeline.slash")
@dataclass
class SlashCommand:
"""单个 slash 命令。handler 签名async def handler(args, ctx) -> str
- args: 命令后的参数字符串不含命令名
- ctx: AgentExecutor 上下文 dictproject_id/user_id/pipeline_id/workspace_dir/config/executor
"""
name: str # 不含 "/" 前缀,如 "new"
description: str = ""
handler: Optional[Callable] = None
scope: str = "global" # global / pipeline / role
scope_id: str = "" # pipeline_id 或 pipeline_id:role
args_hint: str = "" # 参数提示,如 "[task_id]"
# ── 全局注册表 ──
_commands: Dict[str, SlashCommand] = {}
def register_slash_command(cmd: SlashCommand):
"""注册一个 slash 命令(幂等:重复注册覆盖)。"""
_commands[cmd.name] = cmd
logger.debug(f"SlashCommand registered: /{cmd.name} ({cmd.scope})")
def get_slash_commands(pipeline_id: str = "", role: str = "") -> Dict[str, SlashCommand]:
"""取当前上下文可见的 slash 命令(通用 + 产线 + 角色)。"""
result: Dict[str, SlashCommand] = {}
for name, cmd in _commands.items():
if cmd.scope == "global":
result[name] = cmd
elif cmd.scope == "pipeline" and pipeline_id and cmd.scope_id == pipeline_id:
result[name] = cmd
elif cmd.scope == "role" and pipeline_id and role \
and cmd.scope_id == f"{pipeline_id}:{role}":
result[name] = cmd
return result
def resolve_slash_command(text: str, pipeline_id: str = "",
role: str = "") -> Optional[SlashCommand]:
"""从用户输入解析 slash 命令text 以 "/" 开头)。"""
if not text or not text.startswith("/"):
return None
body = text[1:].strip()
if not body:
return None
# 命令名 = 第一个空格前的 token
name = body.split()[0] if " " in body else body
visible = get_slash_commands(pipeline_id, role)
return visible.get(name)
def parse_slash_args(text: str) -> str:
"""提取命令名之后的参数字符串。"""
body = text[1:].strip() if text.startswith("/") else text.strip()
parts = body.split(None, 1)
return parts[1] if len(parts) > 1 else ""
def list_commands() -> List[SlashCommand]:
"""列出所有已注册命令。"""
return list(_commands.values())