feat(agent): 添加技能自动路由功能 - 引入 SkillRouter 实现根据用户消息自动推荐技能 - 在构建引擎时集成技能路由器 - 从对话历史中提取最后一条用户消息作为路由输入 - 为技能添加触发关键词和中文标题字段支持 refactor(browser): 重构浏览器自动化为子进程架构 - 将 Playwright 浏览器控制移至独立的子进程 worker - 解决 PyInstaller 打包环境下 C 扩展兼容性问题 - 通过 JSON RPC 协议与浏览器 worker 通信 - 添加工具目录和脚本路径查找机制 feat(skills): 增强技能元数据和UI展示 - 为技能添加 triggers 和 title 字段 - 在技能商店中包含中文标题信息 - 添加 office-viz 技能优先级排序 - 在服务器管理器中返回技能标题 feat(gui): 实现技能选择器UI组件 - 添加带下拉菜单的技能选择器按钮 - 支持中文标题和拼音首字母显示 - 集成会话技能加载和状态管理 - 提供通用技能选项和已启用技能列表 ```
180 lines
6.5 KiB
Python
180 lines
6.5 KiB
Python
"""Skill loading — Anthropic SKILL.md format with progressive disclosure.
|
||
|
||
A skill is a folder containing `SKILL.md` (YAML frontmatter: name, description,
|
||
optional allowed-tools) + a markdown body of instructions + optional resources/scripts.
|
||
|
||
Progressive disclosure: at session start only the catalog (name + description) is injected
|
||
into the agent's context; the full body is loaded on demand via the `load_skill` tool.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Callable, Optional, Union
|
||
|
||
import aisuite as ai
|
||
|
||
|
||
@dataclass
|
||
class Skill:
|
||
name: str
|
||
description: str
|
||
instructions: str = "" # full body — loaded on demand
|
||
path: Optional[str] = None
|
||
allowed_tools: list[str] = field(default_factory=list)
|
||
triggers: list[str] = field(default_factory=list) # 触发关键词,用于自动路由
|
||
title: str = "" # 中文标题,用于 UI 显示(如 "办公可视化看板")
|
||
|
||
|
||
class SkillLoader:
|
||
def __init__(self, dirs: list[str | Path]) -> None:
|
||
self._dirs = [Path(d) for d in dirs]
|
||
self._skills: dict[str, Skill] = {}
|
||
self.rescan()
|
||
|
||
def rescan(self) -> None:
|
||
"""Re-read the skill dirs. load_skill rescans on a miss so a skill created AFTER
|
||
the session's engine was built is still loadable (the catalog line stays static
|
||
until the next session, but an explicitly requested skill must not 404)."""
|
||
self._skills = {}
|
||
for directory in self._dirs:
|
||
self._discover(directory)
|
||
|
||
def _discover(self, directory: Path) -> None:
|
||
if not directory.is_dir():
|
||
return
|
||
for sub in sorted(directory.iterdir()):
|
||
md = sub / "SKILL.md"
|
||
if md.is_file():
|
||
skill = _parse_skill(md)
|
||
self._skills[skill.name] = skill
|
||
|
||
def names(self) -> list[str]:
|
||
return list(self._skills)
|
||
|
||
def get(self, name: str) -> Optional[Skill]:
|
||
return self._skills.get(name)
|
||
|
||
def catalog(self) -> list[dict]:
|
||
# 按优先级排序:office-viz 排最前(数据分析可视化优先),其余按字母顺序
|
||
def sort_key(s: Skill) -> tuple[int, str]:
|
||
if s.name == "office-viz":
|
||
return (0, "")
|
||
return (1, s.name)
|
||
|
||
return [
|
||
{"name": s.name, "description": s.description, "title": s.title}
|
||
for s in sorted(self._skills.values(), key=sort_key)
|
||
]
|
||
|
||
|
||
def _parse_skill(md: Path) -> Skill:
|
||
text = md.read_text(encoding="utf-8")
|
||
name, description, allowed, body = md.parent.name, "", [], text
|
||
triggers: list[str] = []
|
||
title: str = ""
|
||
if text.startswith("---"):
|
||
end = text.find("\n---", 3)
|
||
if end != -1:
|
||
frontmatter = text[3:end]
|
||
body = text[end + 4 :].lstrip("\n")
|
||
for line in frontmatter.splitlines():
|
||
if ":" not in line:
|
||
continue
|
||
key, value = line.split(":", 1)
|
||
key, value = key.strip().lower(), value.strip()
|
||
if key == "name" and value:
|
||
name = value
|
||
elif key == "description":
|
||
description = value
|
||
elif key in ("allowed-tools", "allowed_tools"):
|
||
allowed = [t.strip() for t in value.split(",") if t.strip()]
|
||
elif key == "title":
|
||
title = value
|
||
elif key in ("triggers", "trigger-keywords", "trigger_keywords"):
|
||
# 支持逗号分隔: triggers: 关键词1, 关键词2, 关键词3
|
||
raw = value
|
||
# 去掉首尾可能的引号
|
||
if raw.startswith('"') and raw.endswith('"'):
|
||
raw = raw[1:-1]
|
||
elif raw.startswith("'") and raw.endswith("'"):
|
||
raw = raw[1:-1]
|
||
triggers = [t.strip() for t in raw.split(",") if t.strip()]
|
||
return Skill(
|
||
name=name,
|
||
description=description,
|
||
instructions=body.strip(),
|
||
path=str(md.parent),
|
||
allowed_tools=allowed,
|
||
triggers=triggers,
|
||
title=title,
|
||
)
|
||
|
||
|
||
def skill_catalog_text(
|
||
loader: SkillLoader, allowed: Optional[set[str]] = None
|
||
) -> str:
|
||
catalog = [
|
||
c for c in loader.catalog() if allowed is None or c["name"] in allowed
|
||
]
|
||
if not catalog:
|
||
return ""
|
||
|
||
lines = [f"- {c['name']}: {c['description']}" for c in catalog]
|
||
|
||
# 在列表前加优先级提示
|
||
priority_notice = (
|
||
"⚠️ Skill 选择优先级说明:\n"
|
||
" • 数据分析 + 可视化 + 看板 → 使用 office-viz(不要用 xlsx)\n"
|
||
" • 纯文件读写(无分析) → 使用 xlsx\n"
|
||
" • PDF 操作 → 使用 pdf\n"
|
||
" • ...\n\n"
|
||
)
|
||
return (
|
||
priority_notice +
|
||
"Available skills — call load_skill(name) to load one's full instructions when "
|
||
"it's relevant to the task:\n" + "\n".join(lines)
|
||
)
|
||
|
||
|
||
AllowedSkills = Union[set, Callable[[], set], None]
|
||
|
||
|
||
def skill_tools(loader: SkillLoader, allowed: AllowedSkills = None) -> list:
|
||
"""`allowed` gates load_skill: a set is a build-time snapshot; a CALLABLE is consulted
|
||
on every call — the manager passes one so Settings disables apply to live sessions
|
||
immediately, and skills created after the engine was built are still loadable
|
||
(loader rescans on a miss)."""
|
||
|
||
def _allowed_now() -> Optional[set]:
|
||
return allowed() if callable(allowed) else allowed
|
||
|
||
def load_skill(name: str) -> dict:
|
||
"""Load a skill's full instructions + resources path by name. Call this when a
|
||
skill from the catalog is relevant to the current task."""
|
||
skill = loader.get(name)
|
||
if skill is None:
|
||
loader.rescan() # created after this session started? pick it up now
|
||
skill = loader.get(name)
|
||
gate = _allowed_now()
|
||
if skill is None or (gate is not None and name not in gate):
|
||
available = sorted(
|
||
n for n in loader.names() if gate is None or n in gate
|
||
)
|
||
return {"error": f"unknown skill: {name}", "available": available}
|
||
return {
|
||
"name": skill.name,
|
||
"instructions": skill.instructions,
|
||
"resources_path": skill.path,
|
||
}
|
||
|
||
return [
|
||
ai.tool(
|
||
load_skill,
|
||
metadata=ai.ToolMetadata(
|
||
category="skills", risk_level="low", capabilities=["load_skill"]
|
||
),
|
||
)
|
||
]
|