- 加载器/校验:目录式 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 的分阶段)。
118 lines
3.8 KiB
Go
118 lines
3.8 KiB
Go
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, "\\", "/"))
|
||
}
|