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

151 lines
4.5 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 (
"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)
}
}