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

+150
View File
@@ -0,0 +1,150 @@
package skills
import (
"strings"
"testing"
)
// 本文件专测 design/09 §6.4-3 的能力收缩:**只收不放**。
func TestResolveToolSurface_表驱动(t *testing.T) {
t.Parallel()
registered := []string{"alpha", "beta", "gamma"}
cases := []struct {
name string
allowed []string
disallowed []string
gate Gate
want string
}{
{
name: "未声明上限时不额外收窄(仍受安全闸约束)",
gate: NewNameSet([]string{"alpha", "beta", "gamma"}),
want: "alpha,beta,gamma",
},
{
name: "allowed 是上限:列表外的工具不可见",
allowed: []string{"alpha"},
gate: NewNameSet([]string{"alpha", "beta", "gamma"}),
want: "alpha",
},
{
name: "disallowed 从上限里剔除",
allowed: []string{"alpha", "beta"},
disallowed: []string{"beta"},
gate: NewNameSet([]string{"alpha", "beta", "gamma"}),
want: "alpha",
},
{
name: "安全闸拒绝的工具即使被技能允许也不可用",
allowed: []string{"alpha", "beta"},
gate: NewNameSet([]string{"alpha"}),
want: "alpha",
},
{
name: "allowed 里的幽灵工具不会凭空出现(∩ 注册表)",
allowed: []string{"alpha", "ghost"},
gate: NewNameSet([]string{"alpha", "ghost"}),
want: "alpha",
},
{
name: "缺安全闸 ⇒ 全拒(fail-closed)",
gate: nil,
want: "",
},
{
name: "空注册表 ⇒ 空面",
gate: NewNameSet([]string{"alpha"}),
want: "",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tools := registered
if tc.name == "空注册表 ⇒ 空面" {
tools = nil
}
got := ResolveToolSurface(tools, tc.allowed, tc.disallowed, tc.gate)
if strings.Join(got, ",") != tc.want {
t.Fatalf("ResolveToolSurface = %q, want %q", strings.Join(got, ","), tc.want)
}
})
}
}
// TestResolveToolSurface_只收不放 是本片的核心不变式:无论技能怎么声明,结果永远
// 是「注册表 ∩ 安全闸」的子集 —— 技能**加不进**任何东西。
func TestResolveToolSurface_只收不放(t *testing.T) {
t.Parallel()
registered := []string{"alpha", "beta"}
gate := NewNameSet([]string{"alpha", "beta"})
// 技能把 allowed-tools 写成注册表之外的、或安全闸之外的,都不该出现在结果里。
got := ResolveToolSurface(registered, []string{"ghost", "secret"}, nil, gate)
if len(got) != 0 {
t.Fatalf("ResolveToolSurface = %v, want 空(技能不得放宽任何工具)", got)
}
// 反过来:技能把*所有*注册表工具都写进 allowed,也只能拿到安全闸允许的那些。
got = ResolveToolSurface(registered, registered, nil, NewNameSet([]string{"beta"}))
if strings.Join(got, ",") != "beta" {
t.Fatalf("ResolveToolSurface = %v, want [beta]", got)
}
}
func TestResolveToolSurface_去重与保序(t *testing.T) {
t.Parallel()
got := ResolveToolSurface([]string{"b", "a", "b", " ", "c"}, nil, nil, NewNameSet([]string{"a", "b", "c"}))
if strings.Join(got, ",") != "b,a,c" {
t.Fatalf("ResolveToolSurface = %q, want b,a,c(保注册表序 + 去重 + 跳空白)", strings.Join(got, ","))
}
}
func TestAllOf_全允许才允许(t *testing.T) {
t.Parallel()
allowAll := NewNameSet([]string{"a", "b"})
onlyA := NewNameSet([]string{"a"})
if !AllOf(allowAll, onlyA).Allow("a") {
t.Fatal("AllOf(allowAll, onlyA).Allow(a) = false, want true")
}
if AllOf(allowAll, onlyA).Allow("b") {
t.Fatal("AllOf(allowAll, onlyA).Allow(b) = true, want false")
}
if AllOf(allowAll, GateFunc(nil)).Allow("a") {
t.Fatal("含 nil 函数闸时应当全拒")
}
// 没有任何有效闸 ⇒ 全拒(绝不给出无人看守的工具面)。
if AllOf(nil, nil).Allow("a") {
t.Fatal("AllOf() 全 nil 时应当全拒")
}
if AllOf().Allow("a") {
t.Fatal("AllOf() 无参时应当全拒")
}
}
func TestNameSet_语义(t *testing.T) {
t.Parallel()
set := NewNameSet([]string{" a ", "", "b"})
if !set.Allow("a") || !set.Allow("b") {
t.Fatalf("NameSet = %v, want 含 a/b(并去掉空白)", set)
}
if set.Allow("") || set.Allow("c") {
t.Fatal("NameSet 不该放行未登记的名字")
}
var nilSet NameSet
if nilSet.Allow("a") {
t.Fatal("nil NameSet 必须全拒")
}
}
func TestSortedCopy_不改原切片(t *testing.T) {
t.Parallel()
in := []string{"b", "a"}
got := SortedCopy(in)
if strings.Join(got, ",") != "a,b" {
t.Fatalf("SortedCopy = %v, want [a b]", got)
}
if in[0] != "b" {
t.Fatalf("SortedCopy 改动了入参:%v", in)
}
}