feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)

- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件);
  新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/
  context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死);
  depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w)
- Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点
  (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播
- 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools
- 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定)

⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:35 +08:00
1 parent 29181245ce
commit f75e7a9c9b
17 files changed
+3067 -164

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
package skills
import (
"path"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件实现 design/09 §6.2 里 `triggers` 与 `paths` 两枚字段的**判定语义**:
// 它们一起决定技能是否**自动激活**(对齐 Agent Skills 的 `paths` 限定自动激活)。
//
// 判定本身是纯函数,真正的「激活 + 注入正文」落在 loop 片(design/09 §6.3)。
// MatchPaths 报告 filePaths 里是否有文件命中 patterns 里的任一 glob。
// patterns 为空 ⇒ false(没声明就不自动激活,而不是「任意文件都命中」)。
func MatchPaths(patterns, filePaths []string) bool {
for _, pattern := range patterns {
for _, file := range filePaths {
if MatchGlob(pattern, file) {
return true
}
}
}
return false
}
// MatchGlob 按 glob 语义匹配一个路径。
//
// 语义(刻意写明,避免与别处的 glob 库猜来猜去):
//
// - 路径分隔符统一成 `/`,Windows 的 `\` 会被归一;
// - `*` / `?` / `[...]` 用 path.Match 的单段语义,**不跨目录分隔符**;
// - `**` 匹配**零到多段**(如 `src/**/*.ts` 命中 `src/a/b.ts` 与 `src/b.ts`);
// - 模式里不含 `/` 时,额外按**文件名**匹配一次(写 `*.go` 的人想的是「任何
// .go 文件」,而不是「根目录下的 .go 文件」)。
//
// 语法非法的模式一律返回 false(加载期 Validate 已经拒过它们)。
func MatchGlob(pattern, file string) bool {
pattern = normalizePath(pattern)
file = normalizePath(file)
if pattern == "" || file == "" {
return false
}
if globSegments(strings.Split(pattern, "/"), strings.Split(file, "/")) {
return true
}
if !strings.Contains(pattern, "/") {
base := file
if idx := strings.LastIndex(file, "/"); idx >= 0 {
base = file[idx+1:]
}
// 模式不含 /:额外按文件名匹配一次(写 *.go 的人想的是「任何 .go 文件」)。
// 这里直接走段匹配 —— 再调 MatchGlob 会拿着同样的无斜杠模式无限递归。
return globSegments(strings.Split(pattern, "/"), []string{base})
}
return false
}
// globSegments 递归匹配已切段的两条路径,`**` 可吃掉零到多段。
func globSegments(pattern, name []string) bool {
if len(pattern) == 0 {
return len(name) == 0
}
if pattern[0] == "**" {
for i := 0; i <= len(name); i++ {
if globSegments(pattern[1:], name[i:]) {
return true
}
}
return false
}
if len(name) == 0 {
return false
}
ok, err := path.Match(pattern[0], name[0])
if err != nil || !ok {
return false
}
return globSegments(pattern[1:], name[1:])
}
// MatchTriggers 报告文本里是否出现任一触发词(大小写不敏感的子串匹配)。
// triggers 为空 ⇒ false。
func MatchTriggers(triggers []string, text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, trigger := range triggers {
trimmed := strings.ToLower(strings.TrimSpace(trigger))
if trimmed != "" && strings.Contains(lower, trimmed) {
return true
}
}
return false
}
// AutoActivates 报告技能是否因当前回合的文本/工作文件而**自动激活**:
// 触发词命中**或**工作文件命中 paths。两者都没声明 ⇒ 不自动激活(只能显式调用)。
func AutoActivates(s sdk.Skill, text string, workFiles []string) bool {
if s == nil {
return false
}
spec := sdk.SkillView(s)
triggers, paths := spec.Triggers(), spec.Paths()
if len(triggers) == 0 && len(paths) == 0 {
return false
}
return MatchTriggers(triggers, text) || MatchPaths(paths, workFiles)
}
// normalizePath 统一分隔符并去掉首尾空白;不做其它解释(不做绝对路径归一,
// 判定只关心「模式是否覆盖这个文件」)。
func normalizePath(p string) string {
return strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
}