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

99 lines
4.0 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"
"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
}