Files
AskaEth f75e7a9c9b 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 的分阶段)。
2026-10-06 09:03:35 +08:00

118 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "\\", "/"))
}