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

+135
View File
@@ -0,0 +1,135 @@
package skills
import (
"sort"
"strings"
)
// 本文件实现 design/09 §6.4-3 的「工具面 = 求交集(只收不放)」:
//
// 技能轮次可用工具 = allowed-tools(若声明)∩ 当前安全闸 ∩ 全局注册表
// − disallowed-tools
//
// 🔴 铁律:**任何情况下都不得因为技能而放宽**。因此求值函数只从「注册表给出的
// 全集」里**删**,绝不往里**加**;安全闸缺位(nil)时结果是空集(fail-closed)。
//
// 与 Claude Code 的刻意差异(见 design/09 §6.0 对照表):它的 allowed-tools 语义
// 是「免审批」,明说 *It does not restrict which tools are available*;我们的
// allowed-tools 是**可用工具的上限(子集)**,敏感/写操作仍需代审 + 审计。
// Gate 是当前回合的安全闸:actor 元数据 + `ToolPolicy` + 唤醒态允许集。
// 它由运行层(loop)构造后注入 —— 技能包不自己判定身份,也不缓存判定结果。
//
// 实现必须幂等且并发安全(步骤并行时会同时被问)。
type Gate interface {
// Allow 报告名为 name 的工具在当前安全闸下是否可用。
Allow(name string) bool
}
// GateFunc 把普通函数适配成 Gate。
type GateFunc func(name string) bool
// Allow 实现 Gate;nil 函数一律拒(fail-closed)。
func (f GateFunc) Allow(name string) bool { return f != nil && f(name) }
// NameSet 是按名字集合判定的 Gate —— 唤醒态允许集的自然形态。
type NameSet map[string]struct{}
// NewNameSet 把名字列表转成 NameSet(去空白、忽略空串)。
func NewNameSet(names []string) NameSet {
set := make(NameSet, len(names))
for _, name := range names {
if trimmed := strings.TrimSpace(name); trimmed != "" {
set[trimmed] = struct{}{}
}
}
return set
}
// Allow 实现 Gate:只有显式在集合里的名字才可用。
func (s NameSet) Allow(name string) bool {
if s == nil {
return false
}
_, ok := s[name]
return ok
}
// AllOf 把多个闸串成「全允许才允许」,用于把 actor 判定、ToolPolicy 判定与唤醒
// 允许集叠在一个 Gate 上喂给 ResolveToolSurface。
//
// **没有任何一个闸(gates 全 nil/空)时一律全拒**:宁可什么都不给,也不给一个
// 无人看守的工具面。
func AllOf(gates ...Gate) Gate {
effective := make([]Gate, 0, len(gates))
for _, g := range gates {
if g != nil {
effective = append(effective, g)
}
}
if len(effective) == 0 {
return GateFunc(nil)
}
return GateFunc(func(name string) bool {
for _, g := range effective {
if !g.Allow(name) {
return false
}
}
return true
})
}
// ResolveToolSurface 计算技能轮次实际可用的工具名。
//
// 参数:
// - registered:全局注册表给出的工具名全集(本轮 dispatcher 真正持有的工具);
// - allowed:技能的 allowed-tools;**空表示技能不额外收窄**(仍受安全闸约束);
// - disallowed:技能的 disallowed-tools,最后减掉;
// - gate:当前安全闸;nil ⇒ 结果为空集(缺闸即全拒)。
//
// 结果按 registered 的顺序输出(去重、跳过空白名),因此调用方传入确定序就得到
// 确定序 —— 函数本身不排序,避免打乱 dispatcher 的既有工具次序。
func ResolveToolSurface(registered, allowed, disallowed []string, gate Gate) []string {
if gate == nil || len(registered) == 0 {
// 缺闸或没有注册表 ⇒ 没有任何工具可用。绝不「因为技能没声明上限就放行」。
return nil
}
allowedSet := NewNameSet(allowed)
disallowedSet := NewNameSet(disallowed)
hasAllowed := len(allowedSet) > 0
out := make([]string, 0, len(registered))
seen := make(map[string]struct{}, len(registered))
for _, name := range registered {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if _, dup := seen[trimmed]; dup {
continue
}
if hasAllowed {
if _, ok := allowedSet[trimmed]; !ok {
continue
}
}
if _, blocked := disallowedSet[trimmed]; blocked {
continue
}
if !gate.Allow(trimmed) {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
// SortedCopy 返回排序后的名字副本。需要确定序的调用方(如从 map 序的注册表读
// 工具名)先用它归一,再交给 ResolveToolSurface。
func SortedCopy(names []string) []string {
out := append([]string(nil), names...)
sort.Strings(out)
return out
}