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