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

@@ -0,0 +1,98 @@
package skills
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件专测 design/09 §6.3 的清单投影:只有 name + description(+when-to-use)、
// 三态过滤、单条预算、条数上限**如实标 truncated**。
func TestBuildListing_三态(t *testing.T) {
t.Parallel()
both := mustSpecSkill(t, "both", "做 A", "在做 A 时", sdk.InvocationBoth)
modelOnly := mustSpecSkill(t, "model-only", "做 B", "", sdk.InvocationModelOnly)
userOnly := mustSpecSkill(t, "user-only", "做 C", "", sdk.InvocationUserOnly)
listing := BuildListing([]sdk.Skill{both, modelOnly, userOnly})
if len(listing.Entries) != 3 {
t.Fatalf("条目数 = %d, want 3(user-only 也要在清单里可见)", len(listing.Entries))
}
if got := listing.Entries[0].Description; got != "做 A Use when: 在做 A 时" {
t.Fatalf("both 描述 = %q, want 拼接 description + when-to-use", got)
}
if !listing.Entries[0].ModelInvocable || !listing.Entries[0].UserInvocable {
t.Fatalf("both 应当人与模型都可调:%+v", listing.Entries[0])
}
if listing.Entries[1].Description != "" || !listing.Entries[1].ModelInvocable || listing.Entries[1].UserInvocable {
t.Fatalf("model-only 应当「清单不带描述、只有模型可调」:%+v", listing.Entries[1])
}
if listing.Entries[2].Description == "" || listing.Entries[2].ModelInvocable || !listing.Entries[2].UserInvocable {
t.Fatalf("user-only 应当「清单带描述、模型不可调」:%+v", listing.Entries[2])
}
}
// TestBuildListing_正文不进清单 断言渐进披露的硬规格:清单里没有正文(prompt /
// 步骤文本)—— 上面那些字段是唯一进清单的东西。
func TestBuildListing_正文不进清单(t *testing.T) {
t.Parallel()
skill := mustSpecSkill(t, "s", "描述", "", sdk.InvocationBoth)
listing := BuildListing([]sdk.Skill{skill})
if len(listing.Entries) != 1 {
t.Fatalf("条目数 = %d, want 1", len(listing.Entries))
}
if strings.Contains(listing.Entries[0].Description, "base prompt") {
t.Fatalf("正文泄漏进清单:%q", listing.Entries[0].Description)
}
}
func TestBuildListing_单条预算截断(t *testing.T) {
t.Parallel()
skill := mustSpecSkill(t, "long", strings.Repeat("描", SkillListingDescriptionBudget+500), "", sdk.InvocationBoth)
entry := BuildListing([]sdk.Skill{skill}).Entries[0]
if !entry.Truncated {
t.Fatal("超预算的描述必须被标 Truncated")
}
if got := len([]rune(entry.Description)); got != SkillListingDescriptionBudget {
t.Fatalf("截断后长度 = %d, want %d", got, SkillListingDescriptionBudget)
}
if !strings.HasSuffix(entry.Description, SkillListingTruncatedMarker) {
t.Fatalf("截断没有显式标记:%q", entry.Description[len(entry.Description)-20:])
}
}
func TestBuildListing_条数上限如实标(t *testing.T) {
t.Parallel()
list := make([]sdk.Skill, 0, SkillListingMaxEntries+3)
for i := 0; i < SkillListingMaxEntries+3; i++ {
list = append(list, mustSpecSkill(t, "s"+string(rune('a'+i%26))+string(rune('0'+i/26)), "d", "", sdk.InvocationBoth))
}
listing := BuildListing(list)
if len(listing.Entries) != SkillListingMaxEntries {
t.Fatalf("条目数 = %d, want %d", len(listing.Entries), SkillListingMaxEntries)
}
if !listing.Truncated || listing.Note == "" {
t.Fatalf("超上限必须如实标 Truncated 且给出说明:%+v", listing)
}
}
func TestBuildListing_跳过nil(t *testing.T) {
t.Parallel()
listing := BuildListing([]sdk.Skill{nil, mustSpecSkill(t, "s", "d", "", sdk.InvocationBoth), nil})
if len(listing.Entries) != 1 || listing.Truncated {
t.Fatalf("Listing = %+v, want 1 条且未截断", listing)
}
}
// mustSpecSkill 构造一个带 description/when-to-use/invocation 的技能。
func mustSpecSkill(t *testing.T, name, description, whenToUse string, invocation sdk.Invocation) *Skill {
t.Helper()
skill, err := New(name, description, "base prompt", nil, nil, nil,
WithSpec(Spec{WhenToUse: whenToUse, Invocation: invocation}))
if err != nil {
t.Fatalf("New(%s): %v", name, err)
}
return skill
}