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

160 lines
5.4 KiB
Go

package skills
import (
"fmt"
"path"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Validate 校验技能定义的结构约束(design/09 §6.2 的「校验(必做,错误可分类)」):
//
// - steps[].name 非空、不重复(重复 ⇒ ErrStepDuplicate);
// - steps[].depends 指向存在的步骤(否则 ErrStepDependsMissing);
// - 步骤依赖无环(有环 ⇒ ErrStepCycle,**加载期就拒,运行期绝不挂死**);
// - allowed-tools 与 disallowed-tools 无同名(否则 ErrToolConflict);
// - guardrails[].id 不重复(否则 ErrGuardrailDuplicate);
// - invocation / context 取值合法(否则 sdk.ErrInvalidSkillField);
// - paths 的 glob 语法合法(否则 sdk.ErrInvalidSkillField)。
//
// 加载器在解析后调用它(Load/LoadFile/LoadDir 都过这一关),Runner 在开工前
// 再调用一次 —— 校验器对外部直接构造的 sdk.Skill 同样有效。
func Validate(s sdk.Skill) error {
if s == nil || strings.TrimSpace(s.Name()) == "" {
return fmt.Errorf("skills: validate: %w", ErrInvalidSkill)
}
name := s.Name()
spec := sdk.SkillView(s)
if !spec.Invocation().Valid() {
return fmt.Errorf("skills: validate %q: invocation %q: %w", name, spec.Invocation(), sdk.ErrInvalidSkillField)
}
if !spec.ContextMode().Valid() {
return fmt.Errorf("skills: validate %q: context %q: %w", name, spec.ContextMode(), sdk.ErrInvalidSkillField)
}
if err := validatePaths(name, spec.Paths()); err != nil {
return err
}
if err := validateToolDecls(name, spec.AllowedTools(), spec.DisallowedTools()); err != nil {
return err
}
if err := validateGuardrails(name, s.Guardrails()); err != nil {
return err
}
_, err := TopoOrder(s.Steps())
if err != nil {
return fmt.Errorf("skills: validate %q: %w", name, err)
}
return nil
}
// validatePaths 检查每个 glob 的语法(用 OS 无关的 path.Match 做语法体检:它只
// 报语法错,不做真匹配)。语法错会让「按工作文件自动激活」永远不命中,属于静默
// 失效,因此在加载期就拒。
func validatePaths(name string, patterns []string) error {
for _, p := range patterns {
if _, err := path.Match(p, ""); err != nil {
return fmt.Errorf("skills: validate %q: paths %q: %w", name, p, sdk.ErrInvalidSkillField)
}
}
return nil
}
// validateToolDecls 拒绝「同一工具同时被允许与被禁止」的自相矛盾声明。
func validateToolDecls(name string, allowed, disallowed []string) error {
if len(allowed) == 0 || len(disallowed) == 0 {
return nil
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, tool := range allowed {
allowedSet[strings.TrimSpace(tool)] = struct{}{}
}
for _, tool := range disallowed {
if _, hit := allowedSet[strings.TrimSpace(tool)]; hit {
return fmt.Errorf("skills: validate %q: tool %q: %w", name, tool, ErrToolConflict)
}
}
return nil
}
// validateGuardrails 拒绝重复的 guardrail id(重复让「哪条 guardrail」说不清)。
func validateGuardrails(name string, guardrails []sdk.Guardrail) error {
seen := make(map[string]struct{}, len(guardrails))
for _, g := range guardrails {
id := strings.TrimSpace(g.ID)
if _, dup := seen[id]; dup {
return fmt.Errorf("skills: validate %q: guardrail %q: %w", name, g.ID, ErrGuardrailDuplicate)
}
seen[id] = struct{}{}
}
return nil
}
// TopoOrder 返回按依赖拓扑排序后的步骤名。就绪步骤按**声明顺序**出队,因此输出
// 是确定的(同样的定义永远得到同样的顺序)。有任何一步还没排进去就说明依赖成环,
// 此时返回 ErrStepCycle 并如实列出未排入的步骤名。
//
// 步骤名重复、为空、或 depends 指向不存在的步骤,都会先在这里被拒。
func TopoOrder(steps []sdk.Step) ([]string, error) {
if len(steps) == 0 {
return nil, nil
}
index := make(map[string]int, len(steps))
remaining := make([]int, len(steps))
dependents := make([][]int, len(steps))
for i, st := range steps {
name := strings.TrimSpace(st.Name)
if name == "" {
return nil, fmt.Errorf("skills: topo order: step #%d has a blank name: %w", i, ErrInvalidSkill)
}
if _, dup := index[name]; dup {
return nil, fmt.Errorf("skills: topo order: step %q: %w", name, ErrStepDuplicate)
}
index[name] = i
}
for i, st := range steps {
for _, dep := range st.Depends {
depIdx, ok := index[strings.TrimSpace(dep)]
if !ok {
return nil, fmt.Errorf("skills: topo order: step %q depends on unknown step %q: %w",
st.Name, dep, ErrStepDependsMissing)
}
remaining[i]++
dependents[depIdx] = append(dependents[depIdx], i)
}
}
queue := make([]int, 0, len(steps))
for i := range steps {
if remaining[i] == 0 {
queue = append(queue, i)
}
}
order := make([]string, 0, len(steps))
for len(queue) > 0 {
idx := queue[0]
queue = queue[1:]
order = append(order, steps[idx].Name)
for _, dep := range dependents[idx] {
remaining[dep]--
if remaining[dep] == 0 {
queue = append(queue, dep)
}
}
}
if len(order) != len(steps) {
return nil, fmt.Errorf("skills: topo order: steps %v: %w", unsortedStepNames(steps, remaining), ErrStepCycle)
}
return order, nil
}
// unsortedStepNames 收集没排进拓扑序的步骤名(按声明序,错误信息因此确定)。
func unsortedStepNames(steps []sdk.Step, remaining []int) []string {
out := make([]string, 0, len(steps))
for i, st := range steps {
if remaining[i] > 0 {
out = append(out, st.Name)
}
}
return out
}