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:
1 parent
29181245ce
commit
f75e7a9c9b
17 files changed
+3067
-164
No files matched your search
@@ -0,0 +1,117 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件实现 design/09 §6.2 里 `triggers` 与 `paths` 两枚字段的**判定语义**:
|
||||
// 它们一起决定技能是否**自动激活**(对齐 Agent Skills 的 `paths` 限定自动激活)。
|
||||
//
|
||||
// 判定本身是纯函数,真正的「激活 + 注入正文」落在 loop 片(design/09 §6.3)。
|
||||
|
||||
// MatchPaths 报告 filePaths 里是否有文件命中 patterns 里的任一 glob。
|
||||
// patterns 为空 ⇒ false(没声明就不自动激活,而不是「任意文件都命中」)。
|
||||
func MatchPaths(patterns, filePaths []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
for _, file := range filePaths {
|
||||
if MatchGlob(pattern, file) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchGlob 按 glob 语义匹配一个路径。
|
||||
//
|
||||
// 语义(刻意写明,避免与别处的 glob 库猜来猜去):
|
||||
//
|
||||
// - 路径分隔符统一成 `/`,Windows 的 `\` 会被归一;
|
||||
// - `*` / `?` / `[...]` 用 path.Match 的单段语义,**不跨目录分隔符**;
|
||||
// - `**` 匹配**零到多段**(如 `src/**/*.ts` 命中 `src/a/b.ts` 与 `src/b.ts`);
|
||||
// - 模式里不含 `/` 时,额外按**文件名**匹配一次(写 `*.go` 的人想的是「任何
|
||||
// .go 文件」,而不是「根目录下的 .go 文件」)。
|
||||
//
|
||||
// 语法非法的模式一律返回 false(加载期 Validate 已经拒过它们)。
|
||||
func MatchGlob(pattern, file string) bool {
|
||||
pattern = normalizePath(pattern)
|
||||
file = normalizePath(file)
|
||||
if pattern == "" || file == "" {
|
||||
return false
|
||||
}
|
||||
if globSegments(strings.Split(pattern, "/"), strings.Split(file, "/")) {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(pattern, "/") {
|
||||
base := file
|
||||
if idx := strings.LastIndex(file, "/"); idx >= 0 {
|
||||
base = file[idx+1:]
|
||||
}
|
||||
// 模式不含 /:额外按文件名匹配一次(写 *.go 的人想的是「任何 .go 文件」)。
|
||||
// 这里直接走段匹配 —— 再调 MatchGlob 会拿着同样的无斜杠模式无限递归。
|
||||
return globSegments(strings.Split(pattern, "/"), []string{base})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// globSegments 递归匹配已切段的两条路径,`**` 可吃掉零到多段。
|
||||
func globSegments(pattern, name []string) bool {
|
||||
if len(pattern) == 0 {
|
||||
return len(name) == 0
|
||||
}
|
||||
if pattern[0] == "**" {
|
||||
for i := 0; i <= len(name); i++ {
|
||||
if globSegments(pattern[1:], name[i:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(name) == 0 {
|
||||
return false
|
||||
}
|
||||
ok, err := path.Match(pattern[0], name[0])
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
return globSegments(pattern[1:], name[1:])
|
||||
}
|
||||
|
||||
// MatchTriggers 报告文本里是否出现任一触发词(大小写不敏感的子串匹配)。
|
||||
// triggers 为空 ⇒ false。
|
||||
func MatchTriggers(triggers []string, text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, trigger := range triggers {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(trigger))
|
||||
if trimmed != "" && strings.Contains(lower, trimmed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AutoActivates 报告技能是否因当前回合的文本/工作文件而**自动激活**:
|
||||
// 触发词命中**或**工作文件命中 paths。两者都没声明 ⇒ 不自动激活(只能显式调用)。
|
||||
func AutoActivates(s sdk.Skill, text string, workFiles []string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
spec := sdk.SkillView(s)
|
||||
triggers, paths := spec.Triggers(), spec.Paths()
|
||||
if len(triggers) == 0 && len(paths) == 0 {
|
||||
return false
|
||||
}
|
||||
return MatchTriggers(triggers, text) || MatchPaths(paths, workFiles)
|
||||
}
|
||||
|
||||
// normalizePath 统一分隔符并去掉首尾空白;不做其它解释(不做绝对路径归一,
|
||||
// 判定只关心「模式是否覆盖这个文件」)。
|
||||
func normalizePath(p string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件专测 design/09 §6.2 的 triggers / paths 判定语义(自动激活)。
|
||||
|
||||
func TestMatchGlob_表驱动(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
pattern string
|
||||
file string
|
||||
want bool
|
||||
}{
|
||||
{"src/**/*.ts", "src/a.ts", true},
|
||||
{"src/**/*.ts", "src/a/b.ts", true},
|
||||
{"src/**/*.ts", "test/a.ts", false},
|
||||
{"**/*.go", "/repo/internal/x.go", true},
|
||||
{"*.go", "internal/x.go", true}, // 模式不含 /:额外按文件名匹配
|
||||
{"*.go", "internal/x.ts", false}, // 扩展名不符
|
||||
{"internal/*.go", "internal/x.go", true},
|
||||
{"internal/*.go", "internal/sub/x.go", false}, // * 不跨目录分隔符
|
||||
{"**", "anything/at/all", true},
|
||||
{"internal\\x.go", "internal/x.go", true}, // Windows 反斜杠归一
|
||||
{"", "x.go", false},
|
||||
{"x.go", "", false},
|
||||
{"[bad", "x.go", false}, // 语法非法的模式一律 false
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.pattern+"|"+tc.file, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := MatchGlob(tc.pattern, tc.file); got != tc.want {
|
||||
t.Fatalf("MatchGlob(%q, %q) = %v, want %v", tc.pattern, tc.file, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPaths_语义(t *testing.T) {
|
||||
t.Parallel()
|
||||
if MatchPaths(nil, []string{"a.go"}) {
|
||||
t.Fatal("未声明 paths 时不该命中")
|
||||
}
|
||||
if MatchPaths([]string{"*.go"}, nil) {
|
||||
t.Fatal("没有工作文件时不该命中")
|
||||
}
|
||||
if !MatchPaths([]string{"*.md", "*.go"}, []string{"notes.txt", "main.go"}) {
|
||||
t.Fatal("任一模式命中任一文件即应命中")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTriggers_大小写不敏感(t *testing.T) {
|
||||
t.Parallel()
|
||||
if MatchTriggers(nil, "帮我写简报") {
|
||||
t.Fatal("未声明 triggers 时不该命中")
|
||||
}
|
||||
if !MatchTriggers([]string{"简报", " 汇总 "}, "帮我写一份简报") {
|
||||
t.Fatal("触发词应当命中(含去空白)")
|
||||
}
|
||||
if !MatchTriggers([]string{"BRIEF"}, "make a brief") {
|
||||
t.Fatal("触发词应当大小写不敏感")
|
||||
}
|
||||
if MatchTriggers([]string{"简报"}, "") {
|
||||
t.Fatal("空文本不该命中")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoActivates_语义(t *testing.T) {
|
||||
t.Parallel()
|
||||
if AutoActivates(nil, "x", nil) {
|
||||
t.Fatal("nil 技能不该自动激活")
|
||||
}
|
||||
plain := mustSpecSkill(t, "plain", "d", "", sdk.InvocationBoth)
|
||||
if AutoActivates(plain, "简报", []string{"x.go"}) {
|
||||
t.Fatal("未声明 triggers/paths 时不该自动激活(只能显式调用)")
|
||||
}
|
||||
triggered := mustTriggerSkill(t, "t", []string{"简报"}, nil)
|
||||
if !AutoActivates(triggered, "给我一份简报", nil) {
|
||||
t.Fatal("触发词命中即应自动激活")
|
||||
}
|
||||
if AutoActivates(triggered, "无关的话", nil) {
|
||||
t.Fatal("触发词没命中时不该自动激活")
|
||||
}
|
||||
pathed := mustTriggerSkill(t, "p", nil, []string{"src/**/*.go"})
|
||||
if !AutoActivates(pathed, "", []string{"src/a/b.go"}) {
|
||||
t.Fatal("paths 命中即应自动激活")
|
||||
}
|
||||
if AutoActivates(pathed, "", []string{"docs/a.md"}) {
|
||||
t.Fatal("paths 没命中时不该自动激活")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTriggerSkill(t *testing.T, name string, triggers, paths []string) *Skill {
|
||||
t.Helper()
|
||||
skill, err := New(name, "d", "p", nil, nil, nil, WithSpec(Spec{Triggers: triggers, Paths: paths}))
|
||||
if err != nil {
|
||||
t.Fatalf("New(%s): %v", name, err)
|
||||
}
|
||||
return skill
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件实现 design/09 §6.3 的**清单投影**:模型在清单里只看到 name +
|
||||
// description(+when-to-use),**正文(prompt / 步骤渲染文本)不进清单**。正文只在
|
||||
// 技能命中后才展开注入(注入点走 design/14 的唤醒式口径,落在 loop 片)。
|
||||
//
|
||||
// 这里只做「投影 + 预算 + 三态」这一层纯计算,不读盘、不写上下文。
|
||||
|
||||
const (
|
||||
// SkillListingDescriptionBudget 是单条 description(+when-to-use) 的字符上限
|
||||
// (design/09 §6.3-2:照 Agent Skills listing 的 1536 字符上限)。
|
||||
SkillListingDescriptionBudget = 1536
|
||||
// SkillListingMaxEntries 是清单条数的全局上限。超出时**如实标 truncated**,
|
||||
// 绝不静默丢条目(design/09 §6.3-2)。
|
||||
SkillListingMaxEntries = 64
|
||||
// SkillListingTruncatedMarker 是超预算时追加的显式标记。
|
||||
SkillListingTruncatedMarker = "…(truncated)"
|
||||
)
|
||||
|
||||
// ListingEntry 是清单里的一行。它只带名字与描述 —— 正文不在其中。
|
||||
type ListingEntry struct {
|
||||
// Name 是技能名。
|
||||
Name string
|
||||
// Description 是 description 与 when-to-use 拼成的一句说明(按预算截断);
|
||||
// invocation=model-only 时为空(该调用态下清单不带描述,省上下文)。
|
||||
Description string
|
||||
// ModelInvocable 报告模型是否可以调用该技能(invocation=user-only 为 false)。
|
||||
ModelInvocable bool
|
||||
// UserInvocable 报告人是否可以显式调用该技能(invocation=model-only 为 false)。
|
||||
UserInvocable bool
|
||||
// Truncated 报告本行描述是否被预算截断。
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// Listing 是清单投影的结果。
|
||||
type Listing struct {
|
||||
// Entries 是清单条目(保持输入顺序)。
|
||||
Entries []ListingEntry
|
||||
// Truncated 报告条目数是否因 SkillListingMaxEntries 被截断。
|
||||
Truncated bool
|
||||
// Note 是截断的人类可读说明(未截断时为空)。
|
||||
Note string
|
||||
}
|
||||
|
||||
// BuildListing 把技能列表投影成模型可见的清单:三态过滤 + 单条预算 + 条数上限。
|
||||
//
|
||||
// 三态(design/09 §6.3-4,与 Agent Skills 的矩阵逐格对齐):
|
||||
//
|
||||
// - both ⇒ 清单含 description,人与模型都可调;
|
||||
// - model-only ⇒ 清单**不含** description,只有模型能调;
|
||||
// - user-only ⇒ 清单含 description,**模型不可调**。
|
||||
//
|
||||
// nil 技能被跳过。**所有**技能都会出现在清单里(user-only 的技能只是模型不可调,
|
||||
// 但仍要让人在面板里看得到),因此条数上限对三类一视同仁。
|
||||
func BuildListing(list []sdk.Skill) Listing {
|
||||
out := Listing{}
|
||||
limit := SkillListingMaxEntries
|
||||
for _, s := range list {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if len(out.Entries) >= limit {
|
||||
out.Truncated = true
|
||||
break
|
||||
}
|
||||
out.Entries = append(out.Entries, listingEntry(s))
|
||||
}
|
||||
if out.Truncated {
|
||||
out.Note = "skill listing truncated to " + strconv.Itoa(limit) + " entries"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listingEntry 投影一个技能。
|
||||
func listingEntry(s sdk.Skill) ListingEntry {
|
||||
invocation := sdk.SkillView(s).Invocation().OrDefault()
|
||||
entry := ListingEntry{
|
||||
Name: s.Name(),
|
||||
ModelInvocable: invocation.ModelInvocable(),
|
||||
UserInvocable: invocation.UserInvocable(),
|
||||
}
|
||||
if !invocation.ListingHasDescription() {
|
||||
return entry
|
||||
}
|
||||
text := strings.TrimSpace(joinDescription(s.Description(), sdk.SkillView(s).WhenToUse()))
|
||||
entry.Description, entry.Truncated = truncateBudget(text, SkillListingDescriptionBudget)
|
||||
return entry
|
||||
}
|
||||
|
||||
// joinDescription 把 description 与 when-to-use 合成清单里那一句(两者都空则为空)。
|
||||
func joinDescription(description, whenToUse string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if trimmed := strings.TrimSpace(description); trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(whenToUse); trimmed != "" {
|
||||
parts = append(parts, "Use when: "+trimmed)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// truncateBudget 按 rune 截断到 n 个字符以内,并报告是否真的截断了(截断标记也
|
||||
// 计入预算,保证结果长度不超过 n)。
|
||||
func truncateBudget(s string, n int) (string, bool) {
|
||||
if n <= 0 {
|
||||
return s, false
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s, false
|
||||
}
|
||||
marker := []rune(SkillListingTruncatedMarker)
|
||||
if n <= len(marker) {
|
||||
return string(runes[:n]), true
|
||||
}
|
||||
return string(runes[:n-len(marker)]) + SkillListingTruncatedMarker, true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -13,19 +14,25 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Loader loads Skill definitions from YAML — a single stream (Load), a file
|
||||
// (LoadFile), or a directory of configs/skills/*.yaml files (LoadDir). It does
|
||||
// not persist; persist via Store or publish via Register.
|
||||
// 技能定义的磁盘文件名(目录式)。目录式优先于单文件式(design/09 §6.1)。
|
||||
const skillDefinitionFile = "skill.yaml"
|
||||
|
||||
// 目录式技能除 skill.yaml 之外的文件(支持文件)不由加载器读入 —— 它们只作
|
||||
// 只读引用素材,由调用方按 Loaded.Dir 解析(design/09 §6.5)。
|
||||
|
||||
// Loader 从 YAML 加载技能定义:单流(Load)、单文件(LoadFile)、或
|
||||
// configs/skills 目录(LoadDir / LoadDirDetailed)。它只读盘、不落库:需要
|
||||
// 持久化走 Store,需要发布走 Register。
|
||||
type Loader struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewLoader builds a Loader. log may be nil.
|
||||
// NewLoader 构造 Loader。log 可以为 nil。
|
||||
func NewLoader(log *slog.Logger) *Loader {
|
||||
return &Loader{log: sourcedLog(log)}
|
||||
}
|
||||
|
||||
// skillYAML is the on-disk YAML shape of a skill.
|
||||
// skillYAML 是技能定义的磁盘 YAML 形状(design/09 §6.2 定稿字段)。
|
||||
type skillYAML struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
@@ -33,6 +40,21 @@ type skillYAML struct {
|
||||
Steps []yamlStep `yaml:"steps"`
|
||||
Refs []yamlRef `yaml:"refs"`
|
||||
Guardrails []yamlGuardrail `yaml:"guardrails"`
|
||||
|
||||
// 以下为 design/09 §6.2 新增字段。字符串键一律小写连字符,对齐 Agent Skills
|
||||
// 的既有惯例。枚举先按字符串收下,再经 sdk.Parse* 校验(非法值直接拒)。
|
||||
Version int `yaml:"version"`
|
||||
WhenToUse string `yaml:"when-to-use"`
|
||||
Invocation string `yaml:"invocation"`
|
||||
Triggers []string `yaml:"triggers"`
|
||||
Paths []string `yaml:"paths"`
|
||||
AllowedTools []string `yaml:"allowed-tools"`
|
||||
DisallowedTools []string `yaml:"disallowed-tools"`
|
||||
Context string `yaml:"context"`
|
||||
Agent string `yaml:"agent"`
|
||||
Model string `yaml:"model"`
|
||||
Effort string `yaml:"effort"`
|
||||
Metadata map[string]string `yaml:"metadata"`
|
||||
}
|
||||
|
||||
type yamlStep struct {
|
||||
@@ -54,8 +76,22 @@ type yamlGuardrail struct {
|
||||
Enforced bool `yaml:"enforced"`
|
||||
}
|
||||
|
||||
// Load parses one skill from r. It returns ErrInvalidSkill when the document has
|
||||
// a blank name.
|
||||
// Loaded 是一份技能定义及其磁盘来源。Dir 让调用方能够解析目录式技能的**支持
|
||||
// 文件**(只读引用素材)。
|
||||
type Loaded struct {
|
||||
// ID 是磁盘上的技能 id(目录名,或单文件去掉扩展名的文件名)。
|
||||
ID string
|
||||
// Dir 是技能目录(目录式)或定义文件所在目录(单文件式)。
|
||||
Dir string
|
||||
// File 是实际读到的定义文件路径。
|
||||
File string
|
||||
// Skill 是解析并校验后的技能。
|
||||
Skill *Skill
|
||||
}
|
||||
|
||||
// Load 从 r 读一份技能定义。名字为空返回 ErrInvalidSkill;YAML 解码失败返回
|
||||
// 解码错误;结构约束不满足返回 Validate 的可分类错误(ErrStepDependsMissing /
|
||||
// ErrStepCycle / ErrToolConflict / ...)。
|
||||
func (l *Loader) Load(r io.Reader) (*Skill, error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("skills: loader: load: %w", ErrInvalidSkill)
|
||||
@@ -68,11 +104,15 @@ func (l *Loader) Load(r io.Reader) (*Skill, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.log.Info("skill loaded", "skill", skill.Name())
|
||||
if err := Validate(skill); err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: %w", err)
|
||||
}
|
||||
l.log.Info("skill loaded",
|
||||
"skill", skill.Name(), "steps", len(skill.Steps()), "invocation", string(skill.Invocation()))
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// LoadFile loads the single skill YAML file at path.
|
||||
// LoadFile 加载 path 指向的单份技能定义文件。
|
||||
func (l *Loader) LoadFile(path string) (*Skill, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -86,30 +126,116 @@ func (l *Loader) LoadFile(path string) (*Skill, error) {
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// LoadDir loads every *.yaml/*.yml skill file in dir, in readdir order. A file
|
||||
// that fails to parse stops the load. Non-YAML files are skipped.
|
||||
// LoadDir 加载 dir 下的全部技能定义,返回解析并校验后的技能(按技能 id 升序)。
|
||||
// 任一文件解码/校验失败即整体失败(fail fast),绝不半加载。
|
||||
func (l *Loader) LoadDir(dir string) ([]*Skill, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
loaded, err := l.LoadDirDetailed(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err)
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*Skill, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !isYAML(e.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
skill, err := l.LoadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: dir %q: %w", path, err)
|
||||
}
|
||||
out = append(out, skill)
|
||||
out := make([]*Skill, 0, len(loaded))
|
||||
for _, item := range loaded {
|
||||
out = append(out, item.Skill)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toSkill converts the YAML document into a Skill, validating the required name.
|
||||
// LoadDirDetailed 与 LoadDir 相同,但额外返回每份定义的磁盘来源(id / 目录 /
|
||||
// 文件),供调用方解析目录式技能的支持文件。
|
||||
//
|
||||
// 解析顺序(design/09 §6.1「先目录、后文件」):
|
||||
//
|
||||
// - 子目录 <id>/ 含 skill.yaml(或 skill.yml)⇒ 目录式,id = 目录名;
|
||||
// - <id>.yaml / <id>.yml ⇒ 单文件式,id = 去扩展名的文件名;
|
||||
// - 同一 id 两者都在 ⇒ **目录式胜出**,并记一条 WARN(不静默覆盖);
|
||||
// - 子目录里没有 skill.yaml ⇒ 跳过(它可能是支持文件目录);
|
||||
// - 其它文件(含 . 开头的隐藏文件)⇒ 跳过。
|
||||
func (l *Loader) LoadDirDetailed(dir string) ([]Loaded, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err)
|
||||
}
|
||||
byDir, byFile := scanSkillEntries(dir, entries)
|
||||
ids := make([]string, 0, len(byDir)+len(byFile))
|
||||
for id := range byDir {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for id := range byFile {
|
||||
if _, dup := byDir[id]; !dup {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
out := make([]Loaded, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
path, fromDir := byDir[id]
|
||||
if !fromDir {
|
||||
path = byFile[id]
|
||||
} else if filePath, both := byFile[id]; both {
|
||||
l.log.Warn("skill defined as both directory and file; directory wins",
|
||||
"skill", id, "dir_file", path, "file", filePath)
|
||||
}
|
||||
skill, err := l.LoadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: dir %q: %w", dir, err)
|
||||
}
|
||||
out = append(out, Loaded{ID: id, Dir: filepath.Dir(path), File: path, Skill: skill})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanSkillEntries 把目录扫成两张表:目录式技能的 id→skill.yaml 路径,单文件式
|
||||
// 技能的 id→文件路径。
|
||||
func scanSkillEntries(dir string, entries []os.DirEntry) (byDir, byFile map[string]string) {
|
||||
byDir = make(map[string]string)
|
||||
byFile = make(map[string]string)
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
if path := definitionInDir(filepath.Join(dir, name)); path != "" {
|
||||
byDir[name] = path
|
||||
}
|
||||
continue
|
||||
}
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
// 符号链接不追(避免把目录外的定义悄悄引进来);如目标是普通文件,
|
||||
// ReadDir 的 Type 会带 ModeSymlink,这里一律跳过并留给调用方显式加载。
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || !isYAML(name) {
|
||||
continue
|
||||
}
|
||||
byFile[strings.TrimSuffix(name, filepath.Ext(name))] = filepath.Join(dir, name)
|
||||
}
|
||||
return byDir, byFile
|
||||
}
|
||||
|
||||
// definitionInDir 返回子目录里的定义文件路径(skill.yaml 优先,其次 skill.yml);
|
||||
// 都没有则返回空串。
|
||||
func definitionInDir(dir string) string {
|
||||
primary := filepath.Join(dir, skillDefinitionFile)
|
||||
if info, err := os.Stat(primary); err == nil && info.Mode().IsRegular() {
|
||||
return primary
|
||||
}
|
||||
alt := filepath.Join(dir, strings.TrimSuffix(skillDefinitionFile, ".yaml")+".yml")
|
||||
if info, err := os.Stat(alt); err == nil && info.Mode().IsRegular() {
|
||||
return alt
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toSkill 把 YAML 文档转成 Skill:枚举经 sdk.Parse* 校验(非法值直接拒),
|
||||
// 其余字段原样搬运;结构约束由 Validate 负责。
|
||||
func (d skillYAML) toSkill() (*Skill, error) {
|
||||
invocation, err := sdk.ParseInvocation(d.Invocation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err)
|
||||
}
|
||||
contextMode, err := sdk.ParseContextMode(d.Context)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err)
|
||||
}
|
||||
steps := make([]sdk.Step, 0, len(d.Steps))
|
||||
for _, st := range d.Steps {
|
||||
steps = append(steps, sdk.Step{Name: st.Name, Prompt: st.Prompt, Depends: st.Depends})
|
||||
@@ -122,13 +248,27 @@ func (d skillYAML) toSkill() (*Skill, error) {
|
||||
for _, g := range d.Guardrails {
|
||||
guardrails = append(guardrails, sdk.Guardrail{ID: g.ID, Message: g.Message, Enforced: g.Enforced})
|
||||
}
|
||||
skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails)
|
||||
skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails, WithSpec(Spec{
|
||||
Version: d.Version,
|
||||
WhenToUse: d.WhenToUse,
|
||||
Invocation: invocation,
|
||||
Triggers: d.Triggers,
|
||||
Paths: d.Paths,
|
||||
AllowedTools: d.AllowedTools,
|
||||
DisallowedTools: d.DisallowedTools,
|
||||
Context: contextMode,
|
||||
Agent: d.Agent,
|
||||
Model: d.Model,
|
||||
Effort: d.Effort,
|
||||
Metadata: d.Metadata,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// isYAML 报告文件名是否是 YAML(.yaml/.yml,大小写不敏感)。
|
||||
func isYAML(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml")
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// fullSkillYAML 覆盖 design/09 §6.2 的全部新增字段,用来断言「字段真的接上了」。
|
||||
const fullSkillYAML = `
|
||||
name: full
|
||||
description: 全字段技能
|
||||
prompt: base
|
||||
version: 3
|
||||
when-to-use: 当需要全字段校验时
|
||||
invocation: model-only
|
||||
triggers: [汇总, 简报]
|
||||
paths: ["src/**/*.go"]
|
||||
allowed-tools: [search, note]
|
||||
disallowed-tools: [write]
|
||||
context: fork
|
||||
agent: researcher
|
||||
model: fast
|
||||
effort: high
|
||||
metadata:
|
||||
owner: yeiji
|
||||
steps:
|
||||
- name: one
|
||||
prompt: p
|
||||
`
|
||||
|
||||
func TestLoader_Load_新字段_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
sk, err := NewLoader(testLogger()).Load(strings.NewReader(fullSkillYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if sk.Version() != 3 {
|
||||
t.Fatalf("Version = %d, want 3", sk.Version())
|
||||
}
|
||||
if sk.WhenToUse() != "当需要全字段校验时" {
|
||||
t.Fatalf("WhenToUse = %q", sk.WhenToUse())
|
||||
}
|
||||
if sk.Invocation() != sdk.InvocationModelOnly {
|
||||
t.Fatalf("Invocation = %q, want model-only", sk.Invocation())
|
||||
}
|
||||
if got := strings.Join(sk.Triggers(), ","); got != "汇总,简报" {
|
||||
t.Fatalf("Triggers = %q", got)
|
||||
}
|
||||
if got := strings.Join(sk.Paths(), ","); got != "src/**/*.go" {
|
||||
t.Fatalf("Paths = %q", got)
|
||||
}
|
||||
if got := strings.Join(sk.AllowedTools(), ","); got != "search,note" {
|
||||
t.Fatalf("AllowedTools = %q", got)
|
||||
}
|
||||
if got := strings.Join(sk.DisallowedTools(), ","); got != "write" {
|
||||
t.Fatalf("DisallowedTools = %q", got)
|
||||
}
|
||||
if sk.ContextMode() != sdk.ContextFork || sk.Agent() != "researcher" {
|
||||
t.Fatalf("Context/Agent = %q/%q, want fork/researcher", sk.ContextMode(), sk.Agent())
|
||||
}
|
||||
if sk.Model() != "fast" || sk.Effort() != "high" {
|
||||
t.Fatalf("Model/Effort = %q/%q, want fast/high", sk.Model(), sk.Effort())
|
||||
}
|
||||
if got := sk.Metadata()["owner"]; got != "yeiji" {
|
||||
t.Fatalf("Metadata[owner] = %q, want yeiji", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoader_Load_缺省值 断言未声明的字段落到文档规定的缺省(version=1 / both / inline)。
|
||||
func TestLoader_Load_缺省值(t *testing.T) {
|
||||
t.Parallel()
|
||||
sk, err := NewLoader(testLogger()).Load(strings.NewReader("name: bare\nprompt: p\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if sk.Version() != sdk.DefaultSkillVersion || sk.Invocation() != sdk.InvocationBoth || sk.ContextMode() != sdk.ContextInline {
|
||||
t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", sk.Version(), sk.Invocation(), sk.ContextMode())
|
||||
}
|
||||
if sk.AllowedTools() != nil || sk.Triggers() != nil || sk.Metadata() != nil {
|
||||
t.Fatalf("未声明的集合字段应为空:%v/%v/%v", sk.AllowedTools(), sk.Triggers(), sk.Metadata())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoader_Load_非法枚举_异常 断言枚举写错是**显式失败**,不被静默当成缺省。
|
||||
func TestLoader_Load_非法枚举_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"invocation": "name: x\ninvocation: everyone\n",
|
||||
"context": "name: x\ncontext: session\n",
|
||||
"paths": "name: x\npaths: [\"[bad\"]\n",
|
||||
}
|
||||
for name, src := range cases {
|
||||
src := src
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NewLoader(testLogger()).Load(strings.NewReader(src)); !errors.Is(err, sdk.ErrInvalidSkillField) {
|
||||
t.Fatalf("Load(%s) err = %v, want sdk.ErrInvalidSkillField", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoader_Load_结构校验_异常 覆盖 design/09 §6.2 要求的四类「加载期就拒」。
|
||||
func TestLoader_Load_结构校验_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]struct {
|
||||
yaml string
|
||||
want error
|
||||
}{
|
||||
"依赖不存在": {
|
||||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n",
|
||||
want: ErrStepDependsMissing,
|
||||
},
|
||||
"依赖成环": {
|
||||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [b]\n - name: b\n prompt: p\n depends: [a]\n",
|
||||
want: ErrStepCycle,
|
||||
},
|
||||
"自依赖": {
|
||||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [a]\n",
|
||||
want: ErrStepCycle,
|
||||
},
|
||||
"工具自相矛盾": {
|
||||
yaml: "name: x\nallowed-tools: [a, b]\ndisallowed-tools: [b]\n",
|
||||
want: ErrToolConflict,
|
||||
},
|
||||
"护栏重复": {
|
||||
yaml: "name: x\nguardrails:\n - id: g\n message: m\n - id: g\n message: n\n",
|
||||
want: ErrGuardrailDuplicate,
|
||||
},
|
||||
"步骤重名": {
|
||||
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n - name: a\n prompt: p\n",
|
||||
want: ErrStepDuplicate,
|
||||
},
|
||||
"步骤匿名": {
|
||||
yaml: "name: x\nsteps:\n - prompt: p\n",
|
||||
want: ErrInvalidSkill,
|
||||
},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewLoader(testLogger()).Load(strings.NewReader(tc.yaml))
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("%s: err = %v, want %v", name, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoader_LoadDir_目录式与单文件兼容 覆盖 design/09 §6.1 的载体规则:
|
||||
// 目录式 <id>/skill.yaml、单文件 <id>.yaml、同 id 时目录胜出、无定义文件的子目录跳过。
|
||||
func TestLoader_LoadDir_目录式与单文件兼容(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
writeNested(t, dir+"/brief/skill.yaml", "name: brief\nprompt: p\n")
|
||||
writeNested(t, dir+"/brief/notes.md", "支持文件,不该被当成技能")
|
||||
writeFile(t, dir+"/legacy.yaml", "name: legacy\nprompt: p\n")
|
||||
writeFile(t, dir+"/both.yaml", "name: from-file\nprompt: p\n")
|
||||
writeNested(t, dir+"/both/skill.yaml", "name: from-dir\nprompt: p\n")
|
||||
writeNested(t, dir+"/asset/support.txt", "无 skill.yaml 的子目录")
|
||||
writeFile(t, dir+"/.hidden.yaml", "name: hidden\nprompt: p\n")
|
||||
|
||||
loaded, err := NewLoader(testLogger()).LoadDirDetailed(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDirDetailed: %v", err)
|
||||
}
|
||||
if len(loaded) != 3 {
|
||||
t.Fatalf("加载了 %d 份,want 3(both/brief/legacy)", len(loaded))
|
||||
}
|
||||
byID := make(map[string]Loaded, len(loaded))
|
||||
for _, item := range loaded {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
if got := byID["both"].Skill.Name(); got != "from-dir" {
|
||||
t.Fatalf("同 id 目录与文件并存时取到 %q,want from-dir(目录胜出)", got)
|
||||
}
|
||||
if !strings.HasSuffix(filepath.ToSlash(byID["both"].File), "both/skill.yaml") {
|
||||
t.Fatalf("both 的来源 = %q, want 目录式定义文件", byID["both"].File)
|
||||
}
|
||||
if !strings.HasSuffix(filepath.ToSlash(byID["brief"].Dir), "brief") {
|
||||
t.Fatalf("brief 的 Dir = %q, want 指向技能目录(支持文件按它解析)", byID["brief"].Dir)
|
||||
}
|
||||
if !strings.HasSuffix(filepath.ToSlash(byID["legacy"].File), "legacy.yaml") {
|
||||
t.Fatalf("legacy 的来源 = %q, want 单文件", byID["legacy"].File)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoader_LoadDir_坏定义即整体失败 断言目录加载是 fail fast:一份坏定义不会
|
||||
// 被跳过(那会让「配置写错」看起来像「没这个技能」)。
|
||||
func TestLoader_LoadDir_坏定义即整体失败(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir+"/ok.yaml", "name: ok\nprompt: p\n")
|
||||
writeFile(t, dir+"/bad.yaml", "name: bad\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n")
|
||||
if _, err := NewLoader(testLogger()).LoadDir(dir); !errors.Is(err, ErrStepDependsMissing) {
|
||||
t.Fatalf("LoadDir err = %v, want ErrStepDependsMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTopoOrder_顺序与环 断言拓扑序确定(就绪步骤按声明序出队),且成环时如实
|
||||
// 列出没排进去的步骤。
|
||||
func TestTopoOrder_顺序与环(t *testing.T) {
|
||||
t.Parallel()
|
||||
order, err := TopoOrder([]sdk.Step{
|
||||
{Name: "a", Prompt: "p"},
|
||||
{Name: "b", Prompt: "p", Depends: []string{"a"}},
|
||||
{Name: "c", Prompt: "p", Depends: []string{"b", "a"}},
|
||||
{Name: "z", Prompt: "p"},
|
||||
{Name: "y", Prompt: "p", Depends: []string{"z"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TopoOrder: %v", err)
|
||||
}
|
||||
if got := strings.Join(order, ","); got != "a,z,b,y,c" {
|
||||
t.Fatalf("TopoOrder = %q, want a,z,b,y,c", got)
|
||||
}
|
||||
|
||||
_, err = TopoOrder([]sdk.Step{
|
||||
{Name: "a", Prompt: "p", Depends: []string{"b"}},
|
||||
{Name: "b", Prompt: "p", Depends: []string{"a"}},
|
||||
{Name: "free", Prompt: "p"},
|
||||
})
|
||||
if !errors.Is(err, ErrStepCycle) {
|
||||
t.Fatalf("TopoOrder(cycle) err = %v, want ErrStepCycle", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") {
|
||||
t.Fatalf("环错误没列出相关步骤:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeNested 写文件并自动建父目录(目录式技能要的是 <id>/skill.yaml)。
|
||||
func writeNested(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
writeFile(t, path, content)
|
||||
}
|
||||
@@ -4,16 +4,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Workflow is a Skill expanded into its executable form: the base system prompt
|
||||
// plus the ordered steps, references, and guardrails, ready to be handed to the
|
||||
// loop/agent executor (M2). The Runner only assembles workflows; it does not
|
||||
// execute commands.
|
||||
// Workflow 是一个展开后的技能:基础系统提示词 + 有序步骤 + 引用 + 约束,
|
||||
// 可以直接交给执行侧。Runner.Expand 只做展开(不改数据),真执行走 RunSkill。
|
||||
type Workflow struct {
|
||||
Name string
|
||||
Description string
|
||||
@@ -21,25 +20,139 @@ type Workflow struct {
|
||||
Steps []sdk.Step
|
||||
Refs []sdk.Ref
|
||||
Guardrails []sdk.Guardrail
|
||||
// Spec 是 design/09 §6.2 定稿的新增字段(含归一化缺省值)。
|
||||
Spec Spec
|
||||
}
|
||||
|
||||
// Runner expands skills into workflows. It is the M5 placeholder for the
|
||||
// skill-execution wrapper: expansion (the real deliverable) is wired now, while
|
||||
// Run awaits M12/loop integration.
|
||||
// GuardrailFinalSubject 是收尾全量复核时传给 GuardrailChecker 的 subject:它表示
|
||||
// 「本次校验的对象是**全部步骤的累计输出**」,而不是某一步。步骤名不可能取到这个
|
||||
// 值(它以 $ 开头,且步骤名还会被 Validate 拒空)。
|
||||
const GuardrailFinalSubject = "$final"
|
||||
|
||||
// StepRequest 是交给 StepExecutor 的一次步骤执行请求。每步 = 一次模型调用
|
||||
// (design/09 §6.4-2),本结构把该步需要的全部信息一次给全,执行侧不必回读技能。
|
||||
type StepRequest struct {
|
||||
// Skill 是技能名。
|
||||
Skill string
|
||||
// Step 是当前步骤(含它自己的 prompt 与依赖声明)。
|
||||
Step sdk.Step
|
||||
// Prompt 是拼好的指令:技能基础提示词 + 本步提示词。
|
||||
Prompt string
|
||||
// Depends 是**本步依赖的**前序步骤输出(键为步骤名)。只有依赖它的步骤才拿得到,
|
||||
// 且输出按 maxStepOutputChars 截断,避免上下文被单步输出撑爆。
|
||||
Depends map[string]string
|
||||
// Input 是调用方给这次技能执行的初始输入(可为空)。
|
||||
Input string
|
||||
// Tools 是**已收缩**的本步可用工具面(见 ResolveToolSurface);执行侧不得放宽。
|
||||
Tools []string
|
||||
// Refs 是技能声明的引用素材。
|
||||
Refs []sdk.Ref
|
||||
// Spec 是技能的完整新增字段(模型/思考档位/上下文模式/元数据等)。
|
||||
Spec Spec
|
||||
}
|
||||
|
||||
// StepResult 是一次步骤执行的结果。
|
||||
type StepResult struct {
|
||||
// Output 是该步的文本输出(给下游步骤与结果汇总用)。
|
||||
Output string
|
||||
}
|
||||
|
||||
// StepExecutor 真正执行技能的某一步(design/09 §6.4-2:每步 = 一次模型调用)。
|
||||
//
|
||||
// 实现**必须**尊重 ctx:父回合取消后应尽快返回(通常是 ctx.Err())。调度器靠
|
||||
// 它在取消/中止时把在飞的步骤收干净,不留下无主的 goroutine。
|
||||
type StepExecutor interface {
|
||||
ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error)
|
||||
}
|
||||
|
||||
// GuardrailChecker 判断一条 guardrail 是否被违反(design/09 §6.4-4 的强制点)。
|
||||
//
|
||||
// subject 说明这次校验的对象:逐步校验时是步骤名,收尾全量复核时是
|
||||
// GuardrailFinalSubject。**校验本身失败**(无法判定)时返回 err,技能随之中止 ——
|
||||
// 绝不把「判不了」当成「通过」。
|
||||
type GuardrailChecker interface {
|
||||
Check(ctx context.Context, g sdk.Guardrail, subject, output string) (violated bool, err error)
|
||||
}
|
||||
|
||||
// GuardrailFunc 把普通函数适配成 GuardrailChecker。
|
||||
type GuardrailFunc func(ctx context.Context, g sdk.Guardrail, subject, output string) (bool, error)
|
||||
|
||||
// Check 实现 GuardrailChecker;nil 函数一律报错(判不了 ≠ 通过)。
|
||||
func (f GuardrailFunc) Check(ctx context.Context, g sdk.Guardrail, subject, output string) (bool, error) {
|
||||
if f == nil {
|
||||
return false, fmt.Errorf("skills: nil guardrail checker: %w", ErrGuardrailCheck)
|
||||
}
|
||||
return f(ctx, g, subject, output)
|
||||
}
|
||||
|
||||
// RunOptions 是一次技能执行的入参与依赖(全部由调用方注入,Runner 不自己找)。
|
||||
type RunOptions struct {
|
||||
// Input 是这次执行的初始输入(用户请求)。
|
||||
Input string
|
||||
// Executor 是步骤执行器。为 nil ⇒ ErrNoExecutor。
|
||||
Executor StepExecutor
|
||||
// Gate 是当前安全闸(actor + ToolPolicy + 唤醒态)。为 nil ⇒ ErrNilGate。
|
||||
Gate Gate
|
||||
// Checker 是 guardrail 校验器。为 nil 时**跳过内容校验**并记一条 WARN:
|
||||
// 结构约束(Validate)仍然生效,但「违规」判定无从谈起。
|
||||
Checker GuardrailChecker
|
||||
// ToolNames 是全局注册表里的工具名全集(通常是本轮 dispatcher 持有的工具)。
|
||||
// 为 nil 时从 Runner 的注册表读 KindTool 能力的名字(需要注册表)。
|
||||
ToolNames []string
|
||||
// MaxParallel 是无依赖步骤的并行上限:非正取默认 2,超过硬上限 3 时夹到 3。
|
||||
MaxParallel int
|
||||
}
|
||||
|
||||
// StepOutcome 是一步的执行结果(按**声明顺序**汇报,因此输出确定)。
|
||||
type StepOutcome struct {
|
||||
// Name 是步骤名。
|
||||
Name string
|
||||
// Output 是该步的文本输出。
|
||||
Output string
|
||||
}
|
||||
|
||||
// Violation 是一条 guardrail 违规事实。enforced 违规会让执行中止,其余只 WARN。
|
||||
type Violation struct {
|
||||
// GuardrailID 是被违反的 guardrail id。
|
||||
GuardrailID string
|
||||
// Subject 是校验对象:步骤名,或 GuardrailFinalSubject。
|
||||
Subject string
|
||||
// Message 是 guardrail 自己声明的规则文本。
|
||||
Message string
|
||||
// Enforced 报告该条是否强制(为真即已中止技能)。
|
||||
Enforced bool
|
||||
}
|
||||
|
||||
// RunResult 是一次技能执行的如实汇报。
|
||||
type RunResult struct {
|
||||
// Skill 是技能名。
|
||||
Skill string
|
||||
// Tools 是本次执行收缩后实际可用的工具面(能力收缩的证据)。
|
||||
Tools []string
|
||||
// Steps 是**已完成**步骤的结果,按声明顺序排列。执行中止时只含已完成的部分
|
||||
// (如实汇报,绝不用空结果冒充完成)。
|
||||
Steps []StepOutcome
|
||||
// Violations 是本次执行发现的所有 guardrail 违规(含 WARN 级)。
|
||||
Violations []Violation
|
||||
}
|
||||
|
||||
// Runner 展开并执行技能。它持有全局能力注册表,用于按名找技能、以及在未显式给出
|
||||
// 工具全集时读出注册表里的工具面。
|
||||
//
|
||||
// Runner 本身无可变状态(注册表内部自带锁),因此并发安全。
|
||||
type Runner struct {
|
||||
reg *registry.Registry
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewRunner builds a Runner over the given registry, used by ExpandByName to
|
||||
// look up registered skills. log may be nil.
|
||||
// NewRunner 在 reg 之上构造 Runner;reg 供 ExpandByName / Run 按名查找技能用,
|
||||
// 也可以为 nil(此时只能用 RunSkill 直接执行)。log 可以为 nil。
|
||||
func NewRunner(reg *registry.Registry, log *slog.Logger) *Runner {
|
||||
return &Runner{reg: reg, log: sourcedLog(log)}
|
||||
}
|
||||
|
||||
// Expand converts s into a Workflow, copying the step, ref, and guardrail slices
|
||||
// so the caller owns the result. It returns ErrInvalidSkill when s is nil or has
|
||||
// a blank name.
|
||||
// Expand 把 s 展开成 Workflow,并复制步骤/引用/约束切片(调用方拥有结果)。
|
||||
// s 为 nil 或名字为空时返回 ErrInvalidSkill。
|
||||
func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Workflow{}, fmt.Errorf("skills: runner: expand: %w", err)
|
||||
@@ -47,6 +160,7 @@ func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
|
||||
if s == nil || strings.TrimSpace(s.Name()) == "" {
|
||||
return Workflow{}, fmt.Errorf("skills: runner: expand: %w", ErrInvalidSkill)
|
||||
}
|
||||
spec := sdk.SkillView(s)
|
||||
wf := Workflow{
|
||||
Name: s.Name(),
|
||||
Description: s.Description(),
|
||||
@@ -54,33 +168,152 @@ func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
|
||||
Steps: cloneSteps(s.Steps()),
|
||||
Refs: cloneRefs(s.Refs()),
|
||||
Guardrails: cloneGuardrails(s.Guardrails()),
|
||||
Spec: specOf(spec),
|
||||
}
|
||||
r.log.Info("skill expanded", "skill", wf.Name)
|
||||
r.log.Info("skill expanded", "skill", wf.Name, "steps", len(wf.Steps))
|
||||
return wf, nil
|
||||
}
|
||||
|
||||
// ExpandByName looks up a skill in the registry by name and expands it. It
|
||||
// returns ErrNilRegistrar when the runner has no registry, the registry's error
|
||||
// (registry.ErrNotFound) when the name is absent, and ErrNotFound when the
|
||||
// registered value is not a Skill.
|
||||
// ExpandByName 按名从注册表找技能并展开。Runner 没有注册表时返回
|
||||
// ErrNilRegistrar;名字不存在时返回注册表的 registry.ErrNotFound;注册表里该名字
|
||||
// 不是技能时返回 ErrNotFound。
|
||||
func (r *Runner) ExpandByName(ctx context.Context, name string) (Workflow, error) {
|
||||
s, err := r.lookup(name)
|
||||
if err != nil {
|
||||
return Workflow{}, err
|
||||
}
|
||||
return r.Expand(ctx, s)
|
||||
}
|
||||
|
||||
// Run 按名从注册表取技能并**真执行**(design/09 §6.4)。
|
||||
//
|
||||
// 错误分类:Runner 无注册表 ⇒ ErrNilRegistrar;技能不存在 ⇒ registry.ErrNotFound;
|
||||
// 定义不合法 ⇒ Validate 的可分类错误;缺安全闸 ⇒ ErrNilGate;缺执行器 ⇒
|
||||
// ErrNoExecutor;某步失败 ⇒ ErrStepFailed(带步骤名);enforced guardrail 违规 ⇒
|
||||
// ErrGuardrailViolation(带 guardrail 与对象);父 ctx 取消 ⇒ ctx.Err()。
|
||||
//
|
||||
// 出错时返回的 RunResult 里只有**已完成**的步骤(诚实的部分结果)。
|
||||
func (r *Runner) Run(ctx context.Context, name string, opts RunOptions) (RunResult, error) {
|
||||
s, err := r.lookup(name)
|
||||
if err != nil {
|
||||
return RunResult{Skill: name}, err
|
||||
}
|
||||
return r.RunSkill(ctx, s, opts)
|
||||
}
|
||||
|
||||
// RunSkill 直接执行一个技能(不经注册表按名查找)。
|
||||
//
|
||||
// 执行语义(design/09 §6.4):
|
||||
//
|
||||
// - 步骤按 depends 拓扑调度,无依赖的步骤可并行(默认 2 路,硬上限 3);
|
||||
// - 每步的可用工具面 = allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools;
|
||||
// - 每步输出后校验 guardrail:enforced 违反即中止并如实报「哪条、哪一步」;
|
||||
// 非 enforced 只 WARN 后继续;全部步骤完成后再做一次累计输出的全量复核;
|
||||
// - 任一步失败即整体失败(不静默跳过);父 ctx 取消即整体取消。
|
||||
func (r *Runner) RunSkill(ctx context.Context, s sdk.Skill, opts RunOptions) (RunResult, error) {
|
||||
if s == nil || strings.TrimSpace(s.Name()) == "" {
|
||||
return RunResult{}, fmt.Errorf("skills: runner: run: %w", ErrInvalidSkill)
|
||||
}
|
||||
name := s.Name()
|
||||
result := RunResult{Skill: name}
|
||||
if opts.Executor == nil {
|
||||
return result, fmt.Errorf("skills: runner: run %q: %w", name, ErrNoExecutor)
|
||||
}
|
||||
if opts.Gate == nil {
|
||||
// 缺安全闸 ⇒ 一律拒。**不能**因为没有闸就把工具全放出去(只收不放)。
|
||||
return result, fmt.Errorf("skills: runner: run %q: %w", name, ErrNilGate)
|
||||
}
|
||||
if err := Validate(s); err != nil {
|
||||
return result, fmt.Errorf("skills: runner: run %q: %w", name, err)
|
||||
}
|
||||
tools, err := r.toolSurface(ctx, s, opts)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Tools = tools
|
||||
if opts.Checker == nil && len(s.Guardrails()) > 0 {
|
||||
r.log.Warn("skill guardrails have no checker; content checks are skipped",
|
||||
"skill", name, "guardrails", len(s.Guardrails()))
|
||||
}
|
||||
env := &runEnv{
|
||||
skill: s,
|
||||
spec: specOf(sdk.SkillView(s)),
|
||||
tools: tools,
|
||||
input: opts.Input,
|
||||
exec: opts.Executor,
|
||||
checker: opts.Checker,
|
||||
parallel: clampParallel(opts.MaxParallel),
|
||||
log: r.log,
|
||||
}
|
||||
return env.run(ctx, result)
|
||||
}
|
||||
|
||||
// lookup 按名从注册表取技能。
|
||||
func (r *Runner) lookup(name string) (sdk.Skill, error) {
|
||||
if r.reg == nil {
|
||||
return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, ErrNilRegistrar)
|
||||
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, ErrNilRegistrar)
|
||||
}
|
||||
cap, err := r.reg.Get(name)
|
||||
if err != nil {
|
||||
return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, err)
|
||||
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, err)
|
||||
}
|
||||
skill, ok := cap.Value.(sdk.Skill)
|
||||
if !ok {
|
||||
return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, ErrNotFound)
|
||||
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, ErrNotFound)
|
||||
}
|
||||
return r.Expand(ctx, skill)
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// Run is a placeholder for executing a registered skill end to end. Execution
|
||||
// lands in the loop/agent executor (M2) and the full wiring (M12); it always
|
||||
// returns ErrRunPending.
|
||||
func (r *Runner) Run(_ context.Context, _ string, _ any) (any, error) {
|
||||
return nil, ErrRunPending
|
||||
// toolSurface 求本次执行的工具面:allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools。
|
||||
func (r *Runner) toolSurface(ctx context.Context, s sdk.Skill, opts RunOptions) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("skills: runner: run %q: %w", s.Name(), err)
|
||||
}
|
||||
spec := sdk.SkillView(s)
|
||||
registered := opts.ToolNames
|
||||
if registered == nil {
|
||||
names, err := r.registeredToolNames()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: runner: run %q: %w", s.Name(), err)
|
||||
}
|
||||
registered = names
|
||||
}
|
||||
return ResolveToolSurface(registered, spec.AllowedTools(), spec.DisallowedTools(), opts.Gate), nil
|
||||
}
|
||||
|
||||
// registeredToolNames 读出注册表里 KindTool 能力的名字(排序后返回,因此本次执行的
|
||||
// 工具面与日志都是确定的)。注册表缺位时报 ErrNilRegistrar —— 工具面必须来自真实的
|
||||
// 注册表,绝不凭空造一个空/全量集合。
|
||||
func (r *Runner) registeredToolNames() ([]string, error) {
|
||||
if r.reg == nil {
|
||||
return nil, ErrNilRegistrar
|
||||
}
|
||||
caps := r.reg.List(registry.KindTool)
|
||||
names := make([]string, 0, len(caps))
|
||||
for _, c := range caps {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// specOf 把 sdk.SkillSpec 读成 Spec(同时补上归一化缺省值),使执行侧只认一种形状。
|
||||
func specOf(spec sdk.SkillSpec) Spec {
|
||||
if spec == nil {
|
||||
return normalizeSpec(Spec{})
|
||||
}
|
||||
return normalizeSpec(Spec{
|
||||
Version: spec.Version(),
|
||||
WhenToUse: spec.WhenToUse(),
|
||||
Invocation: spec.Invocation(),
|
||||
Triggers: spec.Triggers(),
|
||||
Paths: spec.Paths(),
|
||||
AllowedTools: spec.AllowedTools(),
|
||||
DisallowedTools: spec.DisallowedTools(),
|
||||
Context: spec.ContextMode(),
|
||||
Agent: spec.Agent(),
|
||||
Model: spec.Model(),
|
||||
Effort: spec.Effort(),
|
||||
Metadata: spec.Metadata(),
|
||||
})
|
||||
}
|
||||
@@ -3,9 +3,12 @@ package skills
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
func TestRunner_Expand_正常(t *testing.T) {
|
||||
@@ -33,6 +36,9 @@ func TestRunner_Expand_正常(t *testing.T) {
|
||||
if len(wf.Guardrails) != 1 || wf.Guardrails[0].ID != "g1" {
|
||||
t.Fatalf("Guardrails = %+v, want one guardrail g1", wf.Guardrails)
|
||||
}
|
||||
if wf.Spec.Version != sdk.DefaultSkillVersion || wf.Spec.Invocation != sdk.InvocationBoth || wf.Spec.Context != sdk.ContextInline {
|
||||
t.Fatalf("Spec 缺省值 = %+v, want version=1/invocation=both/context=inline", wf.Spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Expand_Nil_异常(t *testing.T) {
|
||||
@@ -80,7 +86,7 @@ func TestRunner_ExpandByName_正常(t *testing.T) {
|
||||
func TestRunner_ExpandByName_NotSkill_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
// Register a capability whose Value is not a Skill (rendered via the raw API).
|
||||
// 注册一个 Value 不是 Skill 的能力(直接用裸 API)。
|
||||
if err := reg.Register(registry.Capability{Name: "notaskill", Kind: registry.KindTool, Value: "x"}); err != nil {
|
||||
t.Fatalf("register tool-cap: %v", err)
|
||||
}
|
||||
@@ -106,10 +112,222 @@ func TestRunner_ExpandByName_NilRegistrar_异常(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_Placeholder_异常(t *testing.T) {
|
||||
// ---- Run / RunSkill:真执行 ----
|
||||
|
||||
// TestRunner_RunSkill_线性依赖按序执行 覆盖最基本的执行路径:一步依赖另一步,
|
||||
// 前序输出按步骤名传给下游,结果按声明序汇报。
|
||||
func TestRunner_RunSkill_线性依赖按序执行(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRunner(registry.New(), testLogger())
|
||||
if _, err := r.Run(context.Background(), "review", nil); !errors.Is(err, ErrRunPending) {
|
||||
t.Fatalf("Run err = %v, want ErrRunPending", err)
|
||||
skill := mustSkill(t, "brief",
|
||||
[]sdk.Step{
|
||||
{Name: "collect", Prompt: "收集"},
|
||||
{Name: "summarize", Prompt: "汇总", Depends: []string{"collect"}},
|
||||
},
|
||||
WithSpec(Spec{AllowedTools: []string{"search", "note"}}))
|
||||
exec := &recordingExecutor{outputs: map[string]string{"collect": "原料", "summarize": "结论"}}
|
||||
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: exec,
|
||||
Gate: NewNameSet([]string{"search", "note", "write"}),
|
||||
ToolNames: []string{"search", "note", "write"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
if got := strings.Join(exec.order(), ","); got != "collect,summarize" {
|
||||
t.Fatalf("执行顺序 = %q, want collect,summarize", got)
|
||||
}
|
||||
if got := exec.depsOf("summarize")["collect"]; got != "原料" {
|
||||
t.Fatalf("summarize 拿到的 collect 输出 = %q, want 原料", got)
|
||||
}
|
||||
if got := strings.Join(res.Tools, ","); got != "search,note" {
|
||||
t.Fatalf("Tools = %q, want search,note(write 被 allowed-tools 收掉)", got)
|
||||
}
|
||||
if len(res.Steps) != 2 || res.Steps[0].Name != "collect" || res.Steps[1].Output != "结论" {
|
||||
t.Fatalf("Steps = %+v, want collect/summarize 两条", res.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_每步工具面一致且已收缩 断言 executor 看到的 Tools 就是
|
||||
// 收缩后的结果(执行侧不可能拿到更宽的面)。
|
||||
func TestRunner_RunSkill_每步工具面一致且已收缩(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustSkill(t, "s", []sdk.Step{{Name: "one", Prompt: "p"}},
|
||||
WithSpec(Spec{AllowedTools: []string{"a"}, DisallowedTools: []string{"a2"}}))
|
||||
exec := &recordingExecutor{}
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: exec,
|
||||
Gate: NewNameSet([]string{"a", "b"}),
|
||||
ToolNames: []string{"a", "a2", "b"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
if got := strings.Join(exec.toolsOf("one"), ","); got != "a" {
|
||||
t.Fatalf("步骤工具面 = %q, want a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RunSkill_校验失败_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 依赖成环 ⇒ 执行前就被 Validate 拦下(运行期绝不挂死)。
|
||||
skill := mustSkill(t, "cycle", []sdk.Step{
|
||||
{Name: "a", Prompt: "p", Depends: []string{"b"}},
|
||||
{Name: "b", Prompt: "p", Depends: []string{"a"}},
|
||||
})
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: &recordingExecutor{}, Gate: NewNameSet(nil), ToolNames: []string{"x"},
|
||||
})
|
||||
if !errors.Is(err, ErrStepCycle) {
|
||||
t.Fatalf("RunSkill(cycle) err = %v, want ErrStepCycle", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RunSkill_缺执行器_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), makeSkill(t, "s"), RunOptions{
|
||||
Gate: NewNameSet(nil),
|
||||
})
|
||||
if !errors.Is(err, ErrNoExecutor) {
|
||||
t.Fatalf("RunSkill 无 executor err = %v, want ErrNoExecutor", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RunSkill_缺安全闸_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), makeSkill(t, "s"), RunOptions{
|
||||
Executor: &recordingExecutor{},
|
||||
})
|
||||
if !errors.Is(err, ErrNilGate) {
|
||||
t.Fatalf("RunSkill 无 gate err = %v, want ErrNilGate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_RunSkill_Nil_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), nil, RunOptions{
|
||||
Executor: &recordingExecutor{}, Gate: NewNameSet(nil),
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidSkill) {
|
||||
t.Fatalf("RunSkill(nil) err = %v, want ErrInvalidSkill", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_Run_按注册表找技能 覆盖 Run 的按名查找路径(含找不到的两种情形)。
|
||||
func TestRunner_Run_按注册表找技能(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
skill := mustSkill(t, "brief", []sdk.Step{{Name: "one", Prompt: "p"}})
|
||||
if err := Register(reg, skill); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
r := NewRunner(reg, testLogger())
|
||||
res, err := r.Run(context.Background(), "brief", RunOptions{
|
||||
Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Skill != "brief" || len(res.Steps) != 1 {
|
||||
t.Fatalf("RunResult = %+v, want brief with one step", res)
|
||||
}
|
||||
|
||||
if _, err := r.Run(context.Background(), "missing", RunOptions{
|
||||
Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
|
||||
}); !errors.Is(err, registry.ErrNotFound) {
|
||||
t.Fatalf("Run(missing) err = %v, want registry.ErrNotFound", err)
|
||||
}
|
||||
if _, err := NewRunner(nil, testLogger()).Run(context.Background(), "brief", RunOptions{
|
||||
Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}),
|
||||
}); !errors.Is(err, ErrNilRegistrar) {
|
||||
t.Fatalf("Run(nil reg) err = %v, want ErrNilRegistrar", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_工具面取自注册表 断言 ToolNames 缺省时从注册表 KindTool 读,
|
||||
// 且技能声明的工具名必须真实存在于注册表 —— 不存在的名字不会凭空出现。
|
||||
func TestRunner_RunSkill_工具面取自注册表(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
sdkTool := stubTool{name: name}
|
||||
if err := reg.RegisterTool(sdkTool); err != nil {
|
||||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
skill := mustSkill(t, "s", []sdk.Step{{Name: "one", Prompt: "p"}},
|
||||
WithSpec(Spec{AllowedTools: []string{"alpha", "ghost"}}))
|
||||
exec := &recordingExecutor{}
|
||||
res, err := NewRunner(reg, testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: exec, Gate: NewNameSet([]string{"alpha", "beta"}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
if got := strings.Join(res.Tools, ","); got != "alpha" {
|
||||
t.Fatalf("Tools = %q, want alpha(beta 被技能收掉,ghost 不在注册表里)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// stubTool 是注册表测试用的最小 sdk.Tool。
|
||||
type stubTool struct{ name string }
|
||||
|
||||
func (s stubTool) Name() string { return s.name }
|
||||
func (s stubTool) Description() string { return "stub " + s.name }
|
||||
func (s stubTool) Parameters() (map[string]any, error) { return map[string]any{"type": "object"}, nil }
|
||||
func (s stubTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
|
||||
|
||||
// mustSkill 构造一个技能,失败即 fatal。
|
||||
func mustSkill(t *testing.T, name string, steps []sdk.Step, opts ...Option) *Skill {
|
||||
t.Helper()
|
||||
skill, err := New(name, "desc "+name, "base prompt", steps, nil, nil, opts...)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%s): %v", name, err)
|
||||
}
|
||||
return skill
|
||||
}
|
||||
|
||||
// recordingExecutor 记录每一步的执行顺序与入参,并可按步骤名定制输出/错误。
|
||||
type recordingExecutor struct {
|
||||
mu sync.Mutex
|
||||
steps []string
|
||||
deps map[string]map[string]string
|
||||
tools map[string][]string
|
||||
outputs map[string]string
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
func (e *recordingExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.deps == nil {
|
||||
e.deps = make(map[string]map[string]string)
|
||||
}
|
||||
if e.tools == nil {
|
||||
e.tools = make(map[string][]string)
|
||||
}
|
||||
e.steps = append(e.steps, req.Step.Name)
|
||||
e.deps[req.Step.Name] = req.Depends
|
||||
e.tools[req.Step.Name] = append([]string(nil), req.Tools...)
|
||||
if err := e.errs[req.Step.Name]; err != nil {
|
||||
return StepResult{}, err
|
||||
}
|
||||
return StepResult{Output: e.outputs[req.Step.Name]}, nil
|
||||
}
|
||||
|
||||
func (e *recordingExecutor) order() []string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return append([]string(nil), e.steps...)
|
||||
}
|
||||
|
||||
func (e *recordingExecutor) depsOf(step string) map[string]string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.deps[step]
|
||||
}
|
||||
|
||||
func (e *recordingExecutor) toolsOf(step string) []string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return append([]string(nil), e.tools[step]...)
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
// Package skills implements the M5 reusable-workflow capability: a Skill is a
|
||||
// higher-order, persistable workflow — an ordered set of steps plus a base
|
||||
// prompt, references, and guardrails — that builds on pkg/plugins/sdk and is
|
||||
// more structured than a Tool (DevDocs/design/09 §0/§3).
|
||||
// Package skills 实现 M5 的可复用工作流能力:一个 Skill 是比 Tool 更结构化、
|
||||
// 且可持久化的「高阶工作流」——一组带提示词的步骤(含依赖关系),配以基础
|
||||
// 提示词、引用与约束(DevDocs/design/09 §0/§3/§6)。
|
||||
//
|
||||
// The package provides five pieces:
|
||||
// 包内五件套:
|
||||
//
|
||||
// - Skill: the concrete in-memory model implementing pkg/plugins/sdk.Skill.
|
||||
// - Store: persistence of skills in a SQLite skills table.
|
||||
// - Loader: loading Skill definitions from YAML (a single stream or a
|
||||
// configs/skills/*.yaml directory).
|
||||
// - Runner: expanding a Skill into an executable Workflow for the loop/agent
|
||||
// executor (the actual execution itself lands in M2/M12).
|
||||
// - Register: publishing skills to the global capability registry
|
||||
// (internal/capability/registry) as KindSkill capabilities.
|
||||
// - Skill:实现 pkg/plugins/sdk.Skill(SkillSpec) 的具象内存模型。
|
||||
// - Store:把技能定义持久化进 SQLite 的 skills 表。
|
||||
// - Loader:从 YAML 加载定义(单流 / 单文件 / configs/skills 目录),并做
|
||||
// 加载期校验(依赖缺失、依赖环、allowed/disallowed 冲突等,见 Validate)。
|
||||
// - Runner:把技能展开成可执行工作流(Expand),并**真执行**(Run/RunSkill):
|
||||
// 步骤拓扑调度 + 无依赖步骤并行(上限 2,硬上限 3)+ guardrail 强制点。
|
||||
// - Register:把技能作为 KindSkill 能力发布进全局能力注册表
|
||||
// (internal/capability/registry)。
|
||||
//
|
||||
// 能力收缩(design/09 §6.4-3)见 surface.go:技能轮次可用工具 = allowed-tools
|
||||
// ∩ 安全闸 ∩ 全局注册表 − disallowed-tools,**任何情况下不因技能放宽**。
|
||||
package skills
|
||||
|
||||
import (
|
||||
@@ -26,29 +28,47 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the skills package. Use errors.Is to test.
|
||||
// 本包的错误哨兵。调用方一律用 errors.Is 分类,绝不靠字符串匹配。
|
||||
var (
|
||||
// ErrInvalidSkill is returned when a skill is nil or has a blank name.
|
||||
// ErrInvalidSkill 表示技能为 nil 或名字为空。
|
||||
ErrInvalidSkill = errors.New("skills: invalid skill")
|
||||
// ErrNotFound is returned by Store when a skill is absent.
|
||||
// ErrNotFound 由 Store 在目标技能不存在时返回。
|
||||
ErrNotFound = errors.New("skills: skill not found")
|
||||
// ErrNilRegistrar is returned by Register/NewRunner when no registry was
|
||||
// supplied.
|
||||
// ErrNilRegistrar 由 Register/NewRunner 在未提供注册表时返回。
|
||||
ErrNilRegistrar = errors.New("skills: nil registrar")
|
||||
// ErrNilDB is returned by NewStore when no *sql.DB was supplied.
|
||||
// ErrNilDB 由 NewStore 在未提供 *sql.DB 时返回。
|
||||
ErrNilDB = errors.New("skills: nil db")
|
||||
// ErrRunPending is returned by Runner.Run, which awaits M12 wiring.
|
||||
ErrRunPending = errors.New("skills: run pending")
|
||||
// ErrStepDuplicate 表示步骤名重复(依赖图会因此歧义,故拒绝)。
|
||||
ErrStepDuplicate = errors.New("skills: duplicate step name")
|
||||
// ErrStepDependsMissing 表示 steps[].depends 指向了不存在的步骤。
|
||||
ErrStepDependsMissing = errors.New("skills: step depends on unknown step")
|
||||
// ErrStepCycle 表示步骤依赖成环(加载期就拒,运行期绝不挂死)。
|
||||
ErrStepCycle = errors.New("skills: step dependency cycle")
|
||||
// ErrToolConflict 表示同一工具同时出现在 allowed-tools 与 disallowed-tools
|
||||
// (自相矛盾的声明,拒绝而不是猜哪边赢)。
|
||||
ErrToolConflict = errors.New("skills: tool both allowed and disallowed")
|
||||
// ErrGuardrailDuplicate 表示 guardrail id 重复。
|
||||
ErrGuardrailDuplicate = errors.New("skills: duplicate guardrail id")
|
||||
// ErrNilGate 表示执行技能时没给安全闸 —— 缺闸即拒绝(只收不放,fail-closed)。
|
||||
ErrNilGate = errors.New("skills: nil tool gate")
|
||||
// ErrNoExecutor 表示执行技能时没给步骤执行器。
|
||||
ErrNoExecutor = errors.New("skills: nil step executor")
|
||||
// ErrStepFailed 表示某一步执行失败(**不静默跳过**,错误里带步骤名)。
|
||||
ErrStepFailed = errors.New("skills: step failed")
|
||||
// ErrGuardrailViolation 表示 enforced guardrail 被违反,技能已中止;
|
||||
// 错误里如实带上「哪条 guardrail、哪一步」。
|
||||
ErrGuardrailViolation = errors.New("skills: guardrail violated")
|
||||
// ErrGuardrailCheck 表示 guardrail 校验器本身失败(与「违规」区分开,
|
||||
// 绝不把校验失败当成通过)。
|
||||
ErrGuardrailCheck = errors.New("skills: guardrail check failed")
|
||||
)
|
||||
|
||||
// Skill is the concrete, persistable form of a skill (see pkg/plugins/sdk.Skill):
|
||||
// a reusable workflow (ordered steps with dependencies), a base prompt,
|
||||
// references, and guardrails. It is more structured than a Tool and is the
|
||||
// higher-order capability described in DevDocs/design/09 §0.
|
||||
// Skill 是技能的可持久化具象形态(对应 pkg/plugins/sdk.Skill):一组带依赖的
|
||||
// 有序步骤 + 基础提示词 + 引用 + 约束,外加 design/09 §6.2 定稿的新增字段
|
||||
// (版本、调用三态、触发词/路径、工具上限、上下文模式、模型覆盖、元数据)。
|
||||
//
|
||||
// Skill is immutable after construction; its getters return copies of the slice
|
||||
// fields, so a *Skill is safe for concurrent use as the sdk.Skill contract
|
||||
// requires.
|
||||
// Skill 构造后不再变化;所有 getter 都返回切片/映射的副本,因此 *Skill 可以按
|
||||
// sdk.Skill 的契约要求并发使用。
|
||||
type Skill struct {
|
||||
name string
|
||||
description string
|
||||
@@ -56,53 +76,178 @@ type Skill struct {
|
||||
steps []sdk.Step
|
||||
refs []sdk.Ref
|
||||
guardrails []sdk.Guardrail
|
||||
spec Spec
|
||||
}
|
||||
|
||||
// compile-time assertion that *Skill satisfies the SDK Skill interface.
|
||||
var _ sdk.Skill = (*Skill)(nil)
|
||||
// 编译期断言:*Skill 同时满足基础 sdk.Skill 与完整的 sdk.SkillSpec。
|
||||
var (
|
||||
_ sdk.Skill = (*Skill)(nil)
|
||||
_ sdk.SkillSpec = (*Skill)(nil)
|
||||
)
|
||||
|
||||
// New builds a Skill. It returns ErrInvalidSkill when name is blank. The step,
|
||||
// ref, and guardrail slices are copied, so the caller remains free to mutate
|
||||
// them afterwards.
|
||||
func New(name, description, prompt string, steps []sdk.Step, refs []sdk.Ref, guardrails []sdk.Guardrail) (*Skill, error) {
|
||||
// Spec 是 design/09 §6.2 定稿的新增字段集合。零值即「全部取缺省」:
|
||||
// version=1、invocation=both、context=inline,其余为空。
|
||||
//
|
||||
// 它同时是 Store 里 spec_json 列的 JSON 形态,因此字段名带 json tag。
|
||||
type Spec struct {
|
||||
// Version 是定义版本(形状迁移用),非正数取缺省 1。
|
||||
Version int `json:"version,omitempty"`
|
||||
// WhenToUse 是「何时使用」补充说明,与 Description 一起进清单并计入预算。
|
||||
WhenToUse string `json:"whenToUse,omitempty"`
|
||||
// Invocation 是调用三态;空取缺省 both。
|
||||
Invocation sdk.Invocation `json:"invocation,omitempty"`
|
||||
// Triggers 是触发词。
|
||||
Triggers []string `json:"triggers,omitempty"`
|
||||
// Paths 是 glob;仅当工作文件命中时自动激活。
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// AllowedTools 是能力**上限**(不是免审批名单),见 design/09 §6.4-3。
|
||||
AllowedTools []string `json:"allowedTools,omitempty"`
|
||||
// DisallowedTools 从上限里剔除。
|
||||
DisallowedTools []string `json:"disallowedTools,omitempty"`
|
||||
// Context 是上下文模式(YAML 键 `context`);空取缺省 inline。
|
||||
Context sdk.ContextMode `json:"context,omitempty"`
|
||||
// Agent 是 context=fork 时使用的子代理类型。
|
||||
Agent string `json:"agent,omitempty"`
|
||||
// Model 是可选模型覆盖;per-role 路由优先于它(design/04 §3)。
|
||||
Model string `json:"model,omitempty"`
|
||||
// Effort 是可选思考档位覆盖。
|
||||
Effort string `json:"effort,omitempty"`
|
||||
// Metadata 是自由 KV,宿主不解释。
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Option 定制 Skill 的构造。目前只有 WithSpec 一条 —— 新增字段整体走 Spec,
|
||||
// 避免 11 个单字段 option 把构造面撑爆。
|
||||
type Option func(*Skill)
|
||||
|
||||
// WithSpec 写入 design/09 §6.2 的新增字段(切片/映射会被复制)。
|
||||
func WithSpec(spec Spec) Option {
|
||||
return func(s *Skill) { s.spec = cloneSpec(spec) }
|
||||
}
|
||||
|
||||
// New 构造一个 Skill。名字为空时返回 ErrInvalidSkill。步骤、引用、约束与 Spec
|
||||
// 都会被复制,因此调用方之后改动入参不会影响已构造的技能。
|
||||
func New(name, description, prompt string, steps []sdk.Step, refs []sdk.Ref, guardrails []sdk.Guardrail, opts ...Option) (*Skill, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("skills: new: %w", ErrInvalidSkill)
|
||||
}
|
||||
return &Skill{
|
||||
s := &Skill{
|
||||
name: name,
|
||||
description: description,
|
||||
prompt: prompt,
|
||||
steps: cloneSteps(steps),
|
||||
refs: cloneRefs(refs),
|
||||
guardrails: cloneGuardrails(guardrails),
|
||||
}, nil
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
s.spec = normalizeSpec(s.spec)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Name returns the unique skill identifier.
|
||||
// Name 返回技能的唯一标识。
|
||||
func (s *Skill) Name() string { return s.name }
|
||||
|
||||
// Description explains what the Skill accomplishes; the model uses it to select
|
||||
// a Skill for a task.
|
||||
// Description 说明技能做什么;模型据此挑选技能。
|
||||
func (s *Skill) Description() string { return s.description }
|
||||
|
||||
// Prompt returns the base system prompt for the Skill.
|
||||
// Prompt 返回技能的基础系统提示词。
|
||||
func (s *Skill) Prompt() string { return s.prompt }
|
||||
|
||||
// Steps returns a copy of the ordered workflow steps.
|
||||
// Steps 返回有序工作流步骤的副本。
|
||||
func (s *Skill) Steps() []sdk.Step { return cloneSteps(s.steps) }
|
||||
|
||||
// Refs returns a copy of the references the Skill may draw on.
|
||||
// Refs 返回技能可引用素材的副本。
|
||||
func (s *Skill) Refs() []sdk.Ref { return cloneRefs(s.refs) }
|
||||
|
||||
// Guardrails returns a copy of the constraints the Skill enforces.
|
||||
// Guardrails 返回技能约束的副本。
|
||||
func (s *Skill) Guardrails() []sdk.Guardrail { return cloneGuardrails(s.guardrails) }
|
||||
|
||||
// Spec 返回新增字段集合的副本(含归一化后的缺省值)。
|
||||
func (s *Skill) Spec() Spec { return cloneSpec(s.spec) }
|
||||
|
||||
// Version 返回定义版本(缺省 1)。
|
||||
func (s *Skill) Version() int { return s.spec.Version }
|
||||
|
||||
// WhenToUse 返回「何时使用」补充说明。
|
||||
func (s *Skill) WhenToUse() string { return s.spec.WhenToUse }
|
||||
|
||||
// Invocation 返回调用三态(缺省 both)。
|
||||
func (s *Skill) Invocation() sdk.Invocation { return s.spec.Invocation }
|
||||
|
||||
// Triggers 返回触发词副本。
|
||||
func (s *Skill) Triggers() []string { return append([]string(nil), s.spec.Triggers...) }
|
||||
|
||||
// Paths 返回 glob 副本。
|
||||
func (s *Skill) Paths() []string { return append([]string(nil), s.spec.Paths...) }
|
||||
|
||||
// AllowedTools 返回能力上限副本(空表示不限,仍受安全闸约束)。
|
||||
func (s *Skill) AllowedTools() []string { return append([]string(nil), s.spec.AllowedTools...) }
|
||||
|
||||
// DisallowedTools 返回剔除名单副本。
|
||||
func (s *Skill) DisallowedTools() []string { return append([]string(nil), s.spec.DisallowedTools...) }
|
||||
|
||||
// ContextMode 返回上下文模式(缺省 inline)。
|
||||
func (s *Skill) ContextMode() sdk.ContextMode { return s.spec.Context }
|
||||
|
||||
// Agent 返回 context=fork 时使用的子代理类型。
|
||||
func (s *Skill) Agent() string { return s.spec.Agent }
|
||||
|
||||
// Model 返回可选模型覆盖。
|
||||
func (s *Skill) Model() string { return s.spec.Model }
|
||||
|
||||
// Effort 返回可选思考档位覆盖。
|
||||
func (s *Skill) Effort() string { return s.spec.Effort }
|
||||
|
||||
// Metadata 返回自由 KV 的副本。
|
||||
func (s *Skill) Metadata() map[string]string { return cloneMetadata(s.spec.Metadata) }
|
||||
|
||||
// normalizeSpec 补上文档规定的缺省值,并把 nil 切片/映射留成 nil(空 == 未声明)。
|
||||
func normalizeSpec(spec Spec) Spec {
|
||||
if spec.Version <= 0 {
|
||||
spec.Version = sdk.DefaultSkillVersion
|
||||
}
|
||||
spec.Invocation = spec.Invocation.OrDefault()
|
||||
spec.Context = spec.Context.OrDefault()
|
||||
return spec
|
||||
}
|
||||
|
||||
func cloneSpec(spec Spec) Spec {
|
||||
out := spec
|
||||
out.Triggers = append([]string(nil), spec.Triggers...)
|
||||
out.Paths = append([]string(nil), spec.Paths...)
|
||||
out.AllowedTools = append([]string(nil), spec.AllowedTools...)
|
||||
out.DisallowedTools = append([]string(nil), spec.DisallowedTools...)
|
||||
out.Metadata = cloneMetadata(spec.Metadata)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMetadata(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneSteps(in []sdk.Step) []sdk.Step {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]sdk.Step, len(in))
|
||||
copy(out, in)
|
||||
for i, st := range in {
|
||||
out[i] = sdk.Step{
|
||||
Name: st.Name,
|
||||
Prompt: st.Prompt,
|
||||
Depends: append([]string(nil), st.Depends...),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -124,8 +269,8 @@ func cloneGuardrails(in []sdk.Guardrail) []sdk.Guardrail {
|
||||
return out
|
||||
}
|
||||
|
||||
// sourcedLog returns a source-tagged logger suitable for this package, falling
|
||||
// back to a discard logger when l is nil so callers can pass nil safely.
|
||||
// sourcedLog 返回带来源标签(source=capability/skills)的日志器;l 为 nil 时
|
||||
// 换成丢弃输出的日志器,让叶子调用方可以安全传 nil。
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
if l == nil {
|
||||
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 步骤调度器:把 design/09 §6.4-1/2/4/5 变成可测的行为。
|
||||
//
|
||||
// - 按 depends 拓扑调度;无依赖(含依赖已就绪)的步骤可并行;
|
||||
// - 并行上限默认 2,硬上限 3(与 design/15 §2.5 的口径一致);
|
||||
// - 每步输出后校验 guardrail:enforced 违反 ⇒ 中止并如实报「哪条、哪一步」,
|
||||
// 非 enforced ⇒ WARN 后继续;
|
||||
// - 全部步骤完成后再做一次**累计输出的全量复核**(design/09 §6.4-4 的「末步
|
||||
// 前再全量复核一次」:并行调度下没有唯一的「末步」,因此落在全部步骤产出之后、
|
||||
// 返回之前,复核对象是累计输出);
|
||||
// - 任一步失败 ⇒ 技能失败(不静默跳过);父 ctx 取消 ⇒ 立即取消收敛。
|
||||
|
||||
const (
|
||||
// defaultStepParallel 是未指定时的步骤并行上限。
|
||||
defaultStepParallel = 2
|
||||
// maxStepParallel 是并行上限的硬顶:技能调用方给再大的值也夹到这里。
|
||||
maxStepParallel = 3
|
||||
// maxStepOutputChars 是单步输出传给下游步骤时的字符上限。步骤输出按有界摘要
|
||||
// 传递(design/09 §6.4-2),避免一步的长输出把后续步骤的上下文撑爆。
|
||||
maxStepOutputChars = 4000
|
||||
// stepTruncatedMarker 是步骤输出被预算截断时的显式标记(绝不静默截断)。
|
||||
stepTruncatedMarker = "…(truncated)"
|
||||
)
|
||||
|
||||
// clampParallel 把并行上限夹进 [1, maxStepParallel]:非正取默认 2,超硬顶夹到 3。
|
||||
func clampParallel(n int) int {
|
||||
if n <= 0 {
|
||||
return defaultStepParallel
|
||||
}
|
||||
if n > maxStepParallel {
|
||||
return maxStepParallel
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// runEnv 是一次技能执行的全部状态(每次 RunSkill 新建一个,因此没有跨执行的共享
|
||||
// 可变状态)。只有调度器 goroutine 会读写它,步骤 goroutine 只读入参、经 channel 回传。
|
||||
type runEnv struct {
|
||||
skill sdk.Skill
|
||||
steps []sdk.Step
|
||||
spec Spec
|
||||
tools []string
|
||||
input string
|
||||
exec StepExecutor
|
||||
checker GuardrailChecker
|
||||
parallel int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// stepDone 是步骤 goroutine 回传给调度器的结果。
|
||||
type stepDone struct {
|
||||
index int
|
||||
output string
|
||||
err error
|
||||
}
|
||||
|
||||
// stepGraph 是步骤依赖图:deps[i] 是步骤 i 依赖的索引,dependents 是反向边,
|
||||
// remaining 是每个步骤还没完成的前序数,ready 是初始就绪队列(按声明序)。
|
||||
type stepGraph struct {
|
||||
deps [][]int
|
||||
dependents [][]int
|
||||
remaining []int
|
||||
ready []int
|
||||
}
|
||||
|
||||
// buildStepGraph 从已校验的步骤建图。名字重复、依赖缺失都会在此二次报错(Validate
|
||||
// 已经拦过一次,这里是执行路径自己的保底)。
|
||||
func buildStepGraph(steps []sdk.Step) (stepGraph, error) {
|
||||
size := len(steps)
|
||||
index := make(map[string]int, size)
|
||||
for i, st := range steps {
|
||||
index[strings.TrimSpace(st.Name)] = i
|
||||
}
|
||||
g := stepGraph{
|
||||
deps: make([][]int, size),
|
||||
dependents: make([][]int, size),
|
||||
remaining: make([]int, size),
|
||||
}
|
||||
for i, st := range steps {
|
||||
for _, dep := range st.Depends {
|
||||
depIdx, ok := index[strings.TrimSpace(dep)]
|
||||
if !ok {
|
||||
return g, fmt.Errorf("skills: step %q depends on unknown step %q: %w",
|
||||
st.Name, dep, ErrStepDependsMissing)
|
||||
}
|
||||
g.deps[i] = append(g.deps[i], depIdx)
|
||||
g.dependents[depIdx] = append(g.dependents[depIdx], i)
|
||||
g.remaining[i]++
|
||||
}
|
||||
}
|
||||
for i := range steps {
|
||||
if g.remaining[i] == 0 {
|
||||
g.ready = append(g.ready, i)
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// run 执行整个技能。base 里已带上技能名与工具面,本函数补上步骤结果与违规清单。
|
||||
func (e *runEnv) run(ctx context.Context, base RunResult) (RunResult, error) {
|
||||
e.steps = e.skill.Steps()
|
||||
if len(e.steps) == 0 {
|
||||
return e.result(base, nil, nil, nil), nil
|
||||
}
|
||||
graph, err := buildStepGraph(e.steps)
|
||||
if err != nil {
|
||||
return e.result(base, nil, nil, nil), fmt.Errorf("skills: runner: run %q: %w", base.Skill, err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
outcomes := make([]StepOutcome, len(e.steps))
|
||||
outputs := make([]string, len(e.steps))
|
||||
finished := make([]bool, len(e.steps))
|
||||
doneCh := make(chan stepDone, e.parallel)
|
||||
violations := make([]Violation, 0)
|
||||
ready := append([]int(nil), graph.ready...)
|
||||
running, completed := 0, 0
|
||||
|
||||
for completed < len(e.steps) {
|
||||
if cerr := ctx.Err(); cerr != nil {
|
||||
e.drain(doneCh, running)
|
||||
return e.result(base, outcomes, finished, violations), e.wrapCanceled(base.Skill, cerr)
|
||||
}
|
||||
for running < e.parallel && len(ready) > 0 {
|
||||
idx := ready[0]
|
||||
ready = ready[1:]
|
||||
running++
|
||||
go e.execStep(ctx, idx, outputs, graph.deps[idx], doneCh)
|
||||
}
|
||||
if running == 0 {
|
||||
// 已经没有在飞步骤、也没有就绪步骤,却仍有步骤没完成 ⇒ 依赖成环。
|
||||
// Validate 已拦过一次,这里兜底返回而不是死等(运行期绝不挂死)。
|
||||
return e.result(base, outcomes, finished, violations),
|
||||
fmt.Errorf("skills: runner: run %q: %w", base.Skill, ErrStepCycle)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
e.drain(doneCh, running)
|
||||
return e.result(base, outcomes, finished, violations), e.wrapCanceled(base.Skill, ctx.Err())
|
||||
case res := <-doneCh:
|
||||
running--
|
||||
if res.err != nil {
|
||||
// 先取消(把信号发给在飞步骤),再收它们 —— 反过来会一直等到
|
||||
// 各步自然结束,等于把失败当成了「等一下就好」。
|
||||
cancel()
|
||||
e.drain(doneCh, running)
|
||||
return e.result(base, outcomes, finished, violations), res.err
|
||||
}
|
||||
outcomes[res.index] = StepOutcome{Name: e.steps[res.index].Name, Output: res.output}
|
||||
outputs[res.index] = res.output
|
||||
finished[res.index] = true
|
||||
completed++
|
||||
found, verr := e.checkStepGuardrails(ctx, e.steps[res.index].Name, res.output)
|
||||
violations = append(violations, found...)
|
||||
if verr != nil {
|
||||
cancel()
|
||||
e.drain(doneCh, running)
|
||||
return e.result(base, outcomes, finished, violations), verr
|
||||
}
|
||||
e.release(graph, res.index, &ready)
|
||||
}
|
||||
}
|
||||
found, verr := e.finalRecheck(ctx, outputs)
|
||||
violations = append(violations, found...)
|
||||
result := e.result(base, outcomes, finished, violations)
|
||||
if verr != nil {
|
||||
return result, verr
|
||||
}
|
||||
e.log.Info("skill run completed",
|
||||
"skill", base.Skill, "steps", completed, "tools", len(base.Tools), "violations", len(violations))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// execStep 跑一步并把结果回传。它由调度器起在独立 goroutine 里,只读共享输入。
|
||||
func (e *runEnv) execStep(ctx context.Context, index int, outputs []string, deps []int, done chan<- stepDone) {
|
||||
step := e.steps[index]
|
||||
if err := ctx.Err(); err != nil {
|
||||
done <- stepDone{index: index, err: e.stepError(step.Name, err)}
|
||||
return
|
||||
}
|
||||
req := StepRequest{
|
||||
Skill: e.skill.Name(),
|
||||
Step: step,
|
||||
Prompt: joinPrompt(e.skill.Prompt(), step.Prompt),
|
||||
Depends: e.depOutputs(deps, outputs),
|
||||
Input: e.input,
|
||||
Tools: append([]string(nil), e.tools...),
|
||||
Refs: e.skill.Refs(),
|
||||
Spec: e.spec,
|
||||
}
|
||||
res, err := e.exec.ExecuteStep(ctx, req)
|
||||
if err != nil {
|
||||
done <- stepDone{index: index, err: e.stepError(step.Name, err)}
|
||||
return
|
||||
}
|
||||
done <- stepDone{index: index, output: res.Output}
|
||||
}
|
||||
|
||||
// depOutputs 取本步依赖的各前序步骤输出(有界摘要),键为步骤名。
|
||||
func (e *runEnv) depOutputs(deps []int, outputs []string) map[string]string {
|
||||
if len(deps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(deps))
|
||||
for _, dep := range deps {
|
||||
out[e.steps[dep].Name] = truncateOutput(outputs[dep], maxStepOutputChars)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// release 把 step 完成的事实传播给它的后继:前序计数归零的后继进入就绪队列。
|
||||
func (e *runEnv) release(graph stepGraph, step int, ready *[]int) {
|
||||
for _, dependent := range graph.dependents[step] {
|
||||
graph.remaining[dependent]--
|
||||
if graph.remaining[dependent] == 0 {
|
||||
*ready = append(*ready, dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkStepGuardrails 在某步输出后校验全部 guardrail。返回发现的违规(非 enforced
|
||||
// 的会一并记下),以及**必须中止**时的错误(enforced 违规、或校验器本身失败)。
|
||||
func (e *runEnv) checkStepGuardrails(ctx context.Context, stepName, output string) ([]Violation, error) {
|
||||
if e.checker == nil {
|
||||
return nil, nil
|
||||
}
|
||||
found := make([]Violation, 0, len(e.skill.Guardrails()))
|
||||
for _, g := range e.skill.Guardrails() {
|
||||
violated, err := e.checker.Check(ctx, g, stepName, output)
|
||||
if err != nil {
|
||||
return found, fmt.Errorf("skills: runner: run %q: guardrail %q at step %q: %w",
|
||||
e.skill.Name(), g.ID, stepName, fmt.Errorf("%w: %w", ErrGuardrailCheck, err))
|
||||
}
|
||||
if !violated {
|
||||
continue
|
||||
}
|
||||
v := Violation{GuardrailID: g.ID, Subject: stepName, Message: g.Message, Enforced: g.Enforced}
|
||||
found = append(found, v)
|
||||
if g.Enforced {
|
||||
return found, e.violationError(v)
|
||||
}
|
||||
e.log.Warn("skill guardrail violated (warn only)",
|
||||
"skill", e.skill.Name(), "guardrail", g.ID, "step", stepName, "message", g.Message)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// finalRecheck 对**全部步骤的累计输出**做一次全量复核(design/09 §6.4-4)。
|
||||
func (e *runEnv) finalRecheck(ctx context.Context, outputs []string) ([]Violation, error) {
|
||||
guardrails := e.skill.Guardrails()
|
||||
if e.checker == nil || len(guardrails) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
joined := truncateOutput(strings.Join(nonEmptyOutputs(outputs, e.steps), "\n\n"), maxStepOutputChars*len(guardrails))
|
||||
found := make([]Violation, 0, len(guardrails))
|
||||
for _, g := range guardrails {
|
||||
violated, err := e.checker.Check(ctx, g, GuardrailFinalSubject, joined)
|
||||
if err != nil {
|
||||
return found, fmt.Errorf("skills: runner: run %q: guardrail %q at %s: %w",
|
||||
e.skill.Name(), g.ID, GuardrailFinalSubject, fmt.Errorf("%w: %w", ErrGuardrailCheck, err))
|
||||
}
|
||||
if !violated {
|
||||
continue
|
||||
}
|
||||
v := Violation{GuardrailID: g.ID, Subject: GuardrailFinalSubject, Message: g.Message, Enforced: g.Enforced}
|
||||
found = append(found, v)
|
||||
if g.Enforced {
|
||||
return found, e.violationError(v)
|
||||
}
|
||||
e.log.Warn("skill guardrail violated on final review (warn only)",
|
||||
"skill", e.skill.Name(), "guardrail", g.ID, "message", g.Message)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// nonEmptyOutputs 取已产出步骤的输出(按声明序),跳过后面的空输出。
|
||||
func nonEmptyOutputs(outputs []string, steps []sdk.Step) []string {
|
||||
out := make([]string, 0, len(outputs))
|
||||
for i, text := range outputs {
|
||||
if text == "" || i >= len(steps) {
|
||||
continue
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// result 组装如实的结果:只汇报**已完成**的步骤(按声明序),违规清单排序后输出,
|
||||
// 因此同样的执行路径总是得到同样的汇报形状。
|
||||
func (e *runEnv) result(base RunResult, outcomes []StepOutcome, finished []bool, violations []Violation) RunResult {
|
||||
out := base
|
||||
out.Steps = make([]StepOutcome, 0, len(outcomes))
|
||||
for i, oc := range outcomes {
|
||||
if finished != nil && i < len(finished) && !finished[i] {
|
||||
continue
|
||||
}
|
||||
out.Steps = append(out.Steps, oc)
|
||||
}
|
||||
out.Violations = sortViolations(violations)
|
||||
return out
|
||||
}
|
||||
|
||||
// sortViolations 按(对象, guardrail id)排序,让并行执行下的汇报顺序确定。
|
||||
func sortViolations(in []Violation) []Violation {
|
||||
out := append([]Violation(nil), in...)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Subject != out[j].Subject {
|
||||
return out[i].Subject < out[j].Subject
|
||||
}
|
||||
return out[i].GuardrailID < out[j].GuardrailID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// drain 等在飞的步骤回传,避免留下无主 goroutine。channel 的缓冲等于并行上限,
|
||||
// 因此回传不会阻塞;调用方必须保证 exec 尊重 ctx(StepExecutor 的契约)。
|
||||
func (e *runEnv) drain(done chan stepDone, running int) {
|
||||
for i := 0; i < running; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// violationError 把一条 enforced 违规变成可分类错误,并**如实带上哪条 guardrail、
|
||||
// 在哪一步**(design/09 §6.4-4 的要求)。
|
||||
func (e *runEnv) violationError(v Violation) error {
|
||||
return fmt.Errorf("skills: runner: run %q: guardrail %q violated at %s: %s: %w",
|
||||
e.skill.Name(), v.GuardrailID, v.Subject, v.Message, ErrGuardrailViolation)
|
||||
}
|
||||
|
||||
// stepError 包装步骤失败:带步骤名,并用 %w 串上 ErrStepFailed 让调用方能分类。
|
||||
func (e *runEnv) stepError(stepName string, err error) error {
|
||||
return fmt.Errorf("skills: runner: run %q: step %q: %w: %w",
|
||||
e.skill.Name(), stepName, ErrStepFailed, err)
|
||||
}
|
||||
|
||||
// wrapCanceled 把父 ctx 的取消如实报出来(errors.Is 能命中 context.Canceled /
|
||||
// context.DeadlineExceeded)。
|
||||
func (e *runEnv) wrapCanceled(skillName string, err error) error {
|
||||
return fmt.Errorf("skills: runner: run %q: %w", skillName, err)
|
||||
}
|
||||
|
||||
// joinPrompt 拼接技能基础提示词与本步提示词;任一段为空时不留孤立空行。
|
||||
func joinPrompt(base, step string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if trimmed := strings.TrimSpace(base); trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(step); trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// truncateOutput 把步骤输出截到 n 个字符以内(按 rune 计,不切断多字节字符),
|
||||
// 超长时追加显式标记 —— 绝不静默截断。
|
||||
func truncateOutput(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + stepTruncatedMarker
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件专测步骤调度与 guardrail 强制点(design/09 §6.4):并行上限、拓扑顺序、
|
||||
// 中止语义、取消传播、收尾全量复核。
|
||||
|
||||
// barrierExecutor 让每一步执行前先登记,再停住等测试放行 —— 用来**观测**两路是否
|
||||
// 真的同时在飞(串行实现会让第二个登记永远不来,测试超时变红)。
|
||||
type barrierExecutor struct {
|
||||
entered chan string
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) {
|
||||
select {
|
||||
case e.entered <- req.Step.Name:
|
||||
case <-ctx.Done():
|
||||
return StepResult{}, ctx.Err()
|
||||
}
|
||||
select {
|
||||
case <-e.release:
|
||||
return StepResult{Output: req.Step.Name}, nil
|
||||
case <-ctx.Done():
|
||||
return StepResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ctxWaitExecutor 一直等到 ctx 取消才返回,用于验证取消传播。
|
||||
type ctxWaitExecutor struct {
|
||||
started chan string
|
||||
}
|
||||
|
||||
func (e *ctxWaitExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) {
|
||||
select {
|
||||
case e.started <- req.Step.Name:
|
||||
default:
|
||||
}
|
||||
<-ctx.Done()
|
||||
return StepResult{}, ctx.Err()
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_无依赖步骤并行 覆盖 design/09 §6.4-1:无依赖的步骤可并行。
|
||||
func TestRunner_RunSkill_无依赖步骤并行(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustSkill(t, "parallel", []sdk.Step{
|
||||
{Name: "a", Prompt: "p"},
|
||||
{Name: "b", Prompt: "p"},
|
||||
})
|
||||
exec := &barrierExecutor{entered: make(chan string, 2), release: make(chan struct{})}
|
||||
result := make(chan RunResult, 1)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
|
||||
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
|
||||
result <- res
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
waitEntered(t, exec.entered, 2)
|
||||
close(exec.release)
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
res := <-result
|
||||
if len(res.Steps) != 2 {
|
||||
t.Fatalf("Steps = %+v, want 2 条", res.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_并行上限夹到硬顶 断言 maxStepParallel=3 是真的咬得住的:
|
||||
// 4 个无依赖步骤 + 申请 100 路 ⇒ 同时最多 3 路在飞,第 4 个不会被提前放出去。
|
||||
func TestRunner_RunSkill_并行上限夹到硬顶(t *testing.T) {
|
||||
t.Parallel()
|
||||
steps := []sdk.Step{
|
||||
{Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p"},
|
||||
{Name: "c", Prompt: "p"}, {Name: "d", Prompt: "p"},
|
||||
}
|
||||
skill := mustSkill(t, "wide", steps)
|
||||
exec := &barrierExecutor{entered: make(chan string, 4), release: make(chan struct{})}
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
|
||||
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, MaxParallel: 100})
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
waitEntered(t, exec.entered, maxStepParallel)
|
||||
select {
|
||||
case got := <-exec.entered:
|
||||
t.Fatalf("第 %q 步在 3 路已满时仍被放出,并行上限没咬住", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
close(exec.release)
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_依赖未就绪不执行 覆盖拓扑语义:两条链 a→b、c→d,
|
||||
// 每个后继都必须在自己前序完成之后才开始。
|
||||
func TestRunner_RunSkill_依赖未就绪不执行(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustSkill(t, "chain", []sdk.Step{
|
||||
{Name: "a", Prompt: "p"},
|
||||
{Name: "b", Prompt: "p", Depends: []string{"a"}},
|
||||
{Name: "c", Prompt: "p"},
|
||||
{Name: "d", Prompt: "p", Depends: []string{"c"}},
|
||||
})
|
||||
exec := &orderGuardExecutor{after: map[string]string{"b": "a", "d": "c"}}
|
||||
if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
|
||||
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
if exec.violation != "" {
|
||||
t.Fatalf("依赖未就绪就执行了:%s", exec.violation)
|
||||
}
|
||||
}
|
||||
|
||||
// orderGuardExecutor 记录完成过的步骤,并断言某步开始时它的前序已完成。
|
||||
type orderGuardExecutor struct {
|
||||
mu sync.Mutex
|
||||
done map[string]bool
|
||||
after map[string]string
|
||||
violation string
|
||||
}
|
||||
|
||||
func (e *orderGuardExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.done == nil {
|
||||
e.done = make(map[string]bool)
|
||||
}
|
||||
if want := e.after[req.Step.Name]; want != "" && !e.done[want] {
|
||||
e.violation = req.Step.Name + " 在 " + want + " 之前就开始"
|
||||
}
|
||||
e.done[req.Step.Name] = true
|
||||
return StepResult{Output: req.Step.Name}, nil
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_Enforced护栏违规中止 覆盖 design/09 §6.4-4:enforced 违反
|
||||
// ⇒ 中止,且错误里如实带「哪条 guardrail、哪一步」,后续步骤不再执行。
|
||||
func TestRunner_RunSkill_Enforced护栏违规中止(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustRunSkill(t, "guarded",
|
||||
[]sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p", Depends: []string{"one"}}},
|
||||
[]sdk.Guardrail{{ID: "no-pii", Message: "不许输出个人信息", Enforced: true}})
|
||||
exec := &recordingExecutor{}
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: exec,
|
||||
Gate: NewNameSet([]string{"t"}),
|
||||
ToolNames: []string{"t"},
|
||||
Checker: GuardrailFunc(func(_ context.Context, g sdk.Guardrail, subject, _ string) (bool, error) {
|
||||
return g.ID == "no-pii" && subject == "one", nil
|
||||
}),
|
||||
})
|
||||
if !errors.Is(err, ErrGuardrailViolation) {
|
||||
t.Fatalf("err = %v, want ErrGuardrailViolation", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no-pii") || !strings.Contains(err.Error(), "one") {
|
||||
t.Fatalf("错误没如实带出哪条 guardrail / 哪一步:%v", err)
|
||||
}
|
||||
if got := strings.Join(exec.order(), ","); got != "one" {
|
||||
t.Fatalf("执行顺序 = %q, want 只有 one(中止后 two 不得执行)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_非Enforced护栏只告警 覆盖「false ⇒ WARN 后继续」。
|
||||
func TestRunner_RunSkill_非Enforced护栏只告警(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustRunSkill(t, "warned",
|
||||
[]sdk.Step{{Name: "one", Prompt: "p"}},
|
||||
[]sdk.Guardrail{{ID: "tone", Message: "语气要软", Enforced: false}})
|
||||
exec := &recordingExecutor{}
|
||||
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: exec,
|
||||
Gate: NewNameSet([]string{"t"}),
|
||||
ToolNames: []string{"t"},
|
||||
Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) {
|
||||
return subject == "one", nil
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
if len(res.Steps) != 1 {
|
||||
t.Fatalf("Steps = %+v, want 1 条(WARN 不阻断)", res.Steps)
|
||||
}
|
||||
if len(res.Violations) == 0 || res.Violations[0].GuardrailID != "tone" || res.Violations[0].Enforced {
|
||||
t.Fatalf("Violations = %+v, want tone/非 enforced 一条", res.Violations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_护栏校验器失败即中止 断言「判不了」不等于「通过」。
|
||||
func TestRunner_RunSkill_护栏校验器失败即中止(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustRunSkill(t, "badcheck",
|
||||
[]sdk.Step{{Name: "one", Prompt: "p"}},
|
||||
[]sdk.Guardrail{{ID: "g", Message: "m", Enforced: false}})
|
||||
boom := errors.New("checker down")
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: &recordingExecutor{},
|
||||
Gate: NewNameSet([]string{"t"}),
|
||||
ToolNames: []string{"t"},
|
||||
Checker: GuardrailFunc(func(context.Context, sdk.Guardrail, string, string) (bool, error) { return false, boom }),
|
||||
})
|
||||
if !errors.Is(err, ErrGuardrailCheck) || !errors.Is(err, boom) {
|
||||
t.Fatalf("err = %v, want 同时命中 ErrGuardrailCheck 与原始错误", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_步骤失败不静默跳过 覆盖 design/09 §6.4-5:任一步失败 ⇒
|
||||
// 技能失败,错误带步骤名,后继步骤不再执行,结果只含已完成的部分。
|
||||
func TestRunner_RunSkill_步骤失败不静默跳过(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustSkill(t, "failing", []sdk.Step{
|
||||
{Name: "one", Prompt: "p"},
|
||||
{Name: "two", Prompt: "p", Depends: []string{"one"}},
|
||||
})
|
||||
boom := errors.New("model exploded")
|
||||
exec := &recordingExecutor{errs: map[string]error{"one": boom}}
|
||||
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
|
||||
})
|
||||
if !errors.Is(err, ErrStepFailed) || !errors.Is(err, boom) {
|
||||
t.Fatalf("err = %v, want 同时命中 ErrStepFailed 与原始错误", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "one") {
|
||||
t.Fatalf("错误没带出失败的步骤名:%v", err)
|
||||
}
|
||||
if got := strings.Join(exec.order(), ","); got != "one" {
|
||||
t.Fatalf("执行顺序 = %q, want 只有 one", got)
|
||||
}
|
||||
if len(res.Steps) != 0 {
|
||||
t.Fatalf("Steps = %+v, want 空(失败步不算完成)", res.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_取消传播 覆盖 design/09 §6.4-5:父 ctx 取消 ⇒ 技能随之取消。
|
||||
func TestRunner_RunSkill_取消传播(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustSkill(t, "cancelled", []sdk.Step{
|
||||
{Name: "a", Prompt: "p"},
|
||||
{Name: "b", Prompt: "p"},
|
||||
})
|
||||
exec := &ctxWaitExecutor{started: make(chan string, 2)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(ctx, skill,
|
||||
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
|
||||
errs <- err
|
||||
}()
|
||||
waitEntered(t, exec.started, 1)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errs:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("取消后 RunSkill 没收敛(疑似遗留 goroutine 或死等)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_收尾全量复核 覆盖 design/09 §6.4-4 的「再全量复核一次」:
|
||||
// 逐步校验都通过,但累计输出违反 enforced guardrail ⇒ 仍然中止。
|
||||
func TestRunner_RunSkill_收尾全量复核(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustRunSkill(t, "final-check",
|
||||
[]sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p"}},
|
||||
[]sdk.Guardrail{{ID: "budget", Message: "总长受限", Enforced: true}})
|
||||
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
|
||||
Executor: &recordingExecutor{outputs: map[string]string{"one": "a", "two": "b"}},
|
||||
Gate: NewNameSet([]string{"t"}),
|
||||
ToolNames: []string{"t"},
|
||||
Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) {
|
||||
return subject == GuardrailFinalSubject, nil
|
||||
}),
|
||||
})
|
||||
if !errors.Is(err, ErrGuardrailViolation) {
|
||||
t.Fatalf("err = %v, want ErrGuardrailViolation", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), GuardrailFinalSubject) {
|
||||
t.Fatalf("错误没说明违规发生在全量复核阶段:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_无步骤直接成功 断言空工作流不报错、也不造出假步骤。
|
||||
func TestRunner_RunSkill_无步骤直接成功(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), mustSkill(t, "empty", nil),
|
||||
RunOptions{Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
|
||||
if err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
if len(res.Steps) != 0 {
|
||||
t.Fatalf("Steps = %+v, want 空", res.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunner_RunSkill_下游输出有界 断言传给下游的步骤输出受 maxStepOutputChars 约束,
|
||||
// 且截断有显式标记(绝不静默)。
|
||||
func TestRunner_RunSkill_下游输出有界(t *testing.T) {
|
||||
t.Parallel()
|
||||
skill := mustSkill(t, "bounded", []sdk.Step{
|
||||
{Name: "one", Prompt: "p"},
|
||||
{Name: "two", Prompt: "p", Depends: []string{"one"}},
|
||||
})
|
||||
exec := &recordingExecutor{outputs: map[string]string{"one": strings.Repeat("x", maxStepOutputChars+50)}}
|
||||
if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
|
||||
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil {
|
||||
t.Fatalf("RunSkill: %v", err)
|
||||
}
|
||||
got := exec.depsOf("two")["one"]
|
||||
if len([]rune(got)) != maxStepOutputChars+len([]rune(stepTruncatedMarker)) {
|
||||
t.Fatalf("传给下游的输出长度 = %d,want %d", len([]rune(got)), maxStepOutputChars+len([]rune(stepTruncatedMarker)))
|
||||
}
|
||||
if !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("截断没有显式标记:%q", got[len(got)-20:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampParallel(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[int]int{
|
||||
0: defaultStepParallel, -3: defaultStepParallel, 1: 1, 2: 2, 3: maxStepParallel, 100: maxStepParallel,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := clampParallel(in); got != want {
|
||||
t.Fatalf("clampParallel(%d) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mustRunSkill 构造一个带 guardrail 的技能;guardrail 的存在使 Validate 之外的分支
|
||||
// 也被覆盖(重复 id、冲突工具在 validate_test.go 里测)。
|
||||
func mustRunSkill(t *testing.T, name string, steps []sdk.Step, guardrails []sdk.Guardrail) *Skill {
|
||||
t.Helper()
|
||||
skill, err := New(name, "desc "+name, "base", steps, nil, guardrails)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%s): %v", name, err)
|
||||
}
|
||||
return skill
|
||||
}
|
||||
|
||||
// waitEntered 从 ch 收 n 个登记;超时即测试失败(说明并行没发生 / 步骤没被执行)。
|
||||
func waitEntered(t *testing.T, ch <-chan string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("只等到 %d/%d 个步骤进入执行(疑似串行或未调度)", i, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,18 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Store persists skill definitions in SQLite (the skills table). It is safe for
|
||||
// concurrent use — the underlying *sql.DB serializes writes — and every query is
|
||||
// parameterized, so values are never interpolated into SQL.
|
||||
// Store 把技能定义持久化进 SQLite 的 skills 表。它并发安全(底层 *sql.DB 串行化
|
||||
// 写),且所有查询都参数化 —— 值永远不拼进 SQL。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// createSkillsTable creates the skills schema. id is an opaque primary key;
|
||||
// name is the stable, unique skill identifier used for Upsert/Get/Delete
|
||||
// lookups. The desc column is double-quoted because DESC is a SQL keyword in
|
||||
// ordering context.
|
||||
// createSkillsTable 创建 skills 表结构。id 是不透明主键;name 是稳定且唯一的技能
|
||||
// 标识(Upsert/Get/Delete 都按它查)。desc 列加双引号是因为 DESC 在排序语境里是
|
||||
// SQL 关键字。spec_json 承载 design/09 §6.2 的新增字段(版本/调用三态/工具上限/
|
||||
// 上下文模式/元数据等)——**必须落盘**:丢掉 allowed-tools 等于把能力收缩放宽,
|
||||
// 那是安全红线,不允许「读回来就变成不限」。
|
||||
const createSkillsTable = `
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -36,37 +36,44 @@ CREATE TABLE IF NOT EXISTS skills (
|
||||
steps_json TEXT NOT NULL DEFAULT '[]',
|
||||
refs_json TEXT NOT NULL DEFAULT '[]',
|
||||
guardrails_json TEXT NOT NULL DEFAULT '[]',
|
||||
spec_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_name ON skills (name);
|
||||
`
|
||||
|
||||
// upsertSkillSQL inserts a skill, updating the existing row when the name
|
||||
// already exists; created_at is preserved across an update.
|
||||
// upsertSkillSQL 插入一条技能;name 已存在时更新该行,created_at 保持不变。
|
||||
const upsertSkillSQL = `
|
||||
INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, spec_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
"desc" = excluded."desc",
|
||||
prompt = excluded.prompt,
|
||||
steps_json = excluded.steps_json,
|
||||
refs_json = excluded.refs_json,
|
||||
guardrails_json = excluded.guardrails_json,
|
||||
spec_json = excluded.spec_json,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
// skillColumns 是读取技能行时的列清单(禁止 SELECT *)。
|
||||
const skillColumns = `name, "desc", prompt, steps_json, refs_json, guardrails_json, spec_json`
|
||||
|
||||
const (
|
||||
getSkillSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills WHERE name = ?`
|
||||
listSkillsSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills ORDER BY name`
|
||||
getSkillSQL = `SELECT ` + skillColumns + ` FROM skills WHERE name = ?`
|
||||
listSkillsSQL = `SELECT ` + skillColumns + ` FROM skills ORDER BY name`
|
||||
deleteSkillSQL = `DELETE FROM skills WHERE name = ?`
|
||||
)
|
||||
|
||||
// idByteLen is the size in bytes of the random skill id (128-bit).
|
||||
// idByteLen 是技能行随机 id 的字节数(128 位)。
|
||||
const idByteLen = 16
|
||||
|
||||
// NewStore builds a Store, creating the skills table if needed. It returns
|
||||
// ErrNilDB when db is nil. ctx is used for the schema creation statement.
|
||||
// specColumnName 是 spec_json 列名;ensureSkillSpecColumn 用它做幂等补列。
|
||||
const specColumnName = "spec_json"
|
||||
|
||||
// NewStore 构造 Store,并在需要时建表/补列。db 为 nil 时返回 ErrNilDB。
|
||||
// ctx 用于建表语句。
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("skills: store: %w", ErrNilDB)
|
||||
@@ -75,13 +82,45 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if _, err := db.ExecContext(ctx, createSkillsTable); err != nil {
|
||||
return nil, fmt.Errorf("skills: store: create skills table: %w", err)
|
||||
}
|
||||
if err := s.ensureSpecColumn(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.log.Info("skills table ready")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Upsert stores skill, inserting a new row or updating the existing row that
|
||||
// shares its name. It returns ErrInvalidSkill when skill is nil or has a blank
|
||||
// name.
|
||||
// ensureSpecColumn 给既有的 skills 表补上 spec_json 列(SQLite 支持 ADD COLUMN 但
|
||||
// 不支持 IF NOT EXISTS,因此先读 pragma 再决定)。CREATE TABLE IF NOT EXISTS 对旧库
|
||||
// 不会改结构,缺这一列就会让写入报错,故必须幂等补齐。
|
||||
func (s *Store) ensureSpecColumn(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT name FROM pragma_table_info('skills')`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: read skills columns: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return fmt.Errorf("skills: store: scan skills column: %w", err)
|
||||
}
|
||||
if name == specColumnName {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("skills: store: read skills columns: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`ALTER TABLE skills ADD COLUMN spec_json TEXT NOT NULL DEFAULT '{}'`); err != nil {
|
||||
return fmt.Errorf("skills: store: add column %s: %w", specColumnName, err)
|
||||
}
|
||||
s.log.Info("skills table migrated", "column", specColumnName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert 写入技能:插入新行,或更新同名的既有行(created_at 保持)。skill 为 nil
|
||||
// 或名字为空时返回 ErrInvalidSkill。实现了 sdk.SkillSpec 的技能会连 Spec 一起落盘;
|
||||
// 只实现基础 sdk.Skill 的技能按各字段的缺省值落盘。
|
||||
func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
|
||||
if skill == nil || strings.TrimSpace(skill.Name()) == "" {
|
||||
return fmt.Errorf("skills: store: upsert: %w", ErrInvalidSkill)
|
||||
@@ -99,10 +138,14 @@ func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: guardrails: %w", name, err)
|
||||
}
|
||||
specJSON, err := marshalJSON(specOf(sdk.SkillView(skill)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: spec: %w", name, err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := s.db.ExecContext(ctx, upsertSkillSQL,
|
||||
newID(), name, skill.Description(), skill.Prompt(),
|
||||
stepsJSON, refsJSON, guardrailsJSON, now, now,
|
||||
stepsJSON, refsJSON, guardrailsJSON, specJSON, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: %w", name, err)
|
||||
}
|
||||
@@ -110,7 +153,7 @@ func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the skill registered under name, or ErrNotFound when absent.
|
||||
// Get 返回名为 name 的技能;不存在时返回 ErrNotFound。
|
||||
func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
|
||||
row := s.db.QueryRowContext(ctx, getSkillSQL, name)
|
||||
skill, err := scanSkill(row, name)
|
||||
@@ -120,7 +163,7 @@ func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// List returns every stored skill, ordered by name.
|
||||
// List 返回全部已存技能,按名字升序。
|
||||
func (s *Store) List(ctx context.Context) ([]*Skill, error) {
|
||||
rows, err := s.db.QueryContext(ctx, listSkillsSQL)
|
||||
if err != nil {
|
||||
@@ -142,8 +185,7 @@ func (s *Store) List(ctx context.Context) ([]*Skill, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes the skill named name. It returns ErrNotFound when no such skill
|
||||
// exists.
|
||||
// Delete 删除名为 name 的技能;不存在时返回 ErrNotFound。
|
||||
func (s *Store) Delete(ctx context.Context, name string) error {
|
||||
res, err := s.db.ExecContext(ctx, deleteSkillSQL, name)
|
||||
if err != nil {
|
||||
@@ -160,14 +202,13 @@ func (s *Store) Delete(ctx context.Context, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rowScanner is the subset of *sql.Row and *sql.Rows that scanSkill needs.
|
||||
// rowScanner 是 scanSkill 需要的 *sql.Row / *sql.Rows 子集。
|
||||
type rowScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
// scanSkill reads one skills row and builds a *Skill. key is the lookup name
|
||||
// used only in error messages.
|
||||
// scanSkill 读一行 skills 记录并构造 *Skill。key 只用于错误文案里的查找名。
|
||||
func scanSkill(sc rowScanner, key string) (*Skill, error) {
|
||||
var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON string
|
||||
if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON); err != nil {
|
||||
var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON, specJSON string
|
||||
if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON, &specJSON); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("skills: store: get %q: %w", key, ErrNotFound)
|
||||
}
|
||||
@@ -185,7 +226,11 @@ func scanSkill(sc rowScanner, key string) (*Skill, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: guardrails: %w", name, err)
|
||||
}
|
||||
skill, err := New(name, desc, prompt, steps, refs, guardrails)
|
||||
spec, err := unmarshalSpec(specJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: spec: %w", name, err)
|
||||
}
|
||||
skill, err := New(name, desc, prompt, steps, refs, guardrails, WithSpec(spec))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: %w", name, err)
|
||||
}
|
||||
@@ -224,12 +269,24 @@ func unmarshalGuardrails(s string) ([]sdk.Guardrail, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newID returns a random 128-bit hex identifier for a skill row.
|
||||
// unmarshalSpec 解析 spec_json;空串按空定义对待(旧行默认 '{}'),非法 JSON 报错
|
||||
// 而不是静默丢掉「工具上限」这类安全相关字段。
|
||||
func unmarshalSpec(s string) (Spec, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return Spec{}, nil
|
||||
}
|
||||
var out Spec
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newID 返回技能行的随机 128 位十六进制标识。
|
||||
func newID() string {
|
||||
var b [idByteLen]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand is effectively infallible; fall back to a nanosecond
|
||||
// timestamp so the store never blocks on a missing identifier.
|
||||
// crypto/rand 实际上不会失败;退化成纳秒时间戳,别让存储因为拿不到 id 卡住。
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
|
||||
@@ -150,9 +150,77 @@ func TestStore_Upsert_Invalid_异常(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_SQLInjection_安全 stores a name/description that looks like SQL and
|
||||
// verifies the values round-trip verbatim (parameterized statements), that the
|
||||
// table survives, and that unrelated rows are unaffected.
|
||||
// TestStore_Spec往返_正常 断言 design/09 §6.2 的新增字段真的落盘并能原样读回 ——
|
||||
// 尤其是 allowed-tools:读回来变成「不限」就等于把能力收缩放宽,那是安全红线,
|
||||
// 所以这里逐字段钉死。
|
||||
func TestStore_Spec往返_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
skill, err := New("guarded", "d", "p", nil, nil, nil, WithSpec(Spec{
|
||||
Version: 2,
|
||||
WhenToUse: "何时用",
|
||||
Invocation: sdk.InvocationUserOnly,
|
||||
Triggers: []string{"简报"},
|
||||
Paths: []string{"src/**/*.go"},
|
||||
AllowedTools: []string{"read"},
|
||||
DisallowedTools: []string{"write"},
|
||||
Context: sdk.ContextFork,
|
||||
Agent: "researcher",
|
||||
Model: "fast",
|
||||
Effort: "high",
|
||||
Metadata: map[string]string{"owner": "yeiji"},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := s.Upsert(ctx, skill); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
got, err := s.Get(ctx, "guarded")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Version() != 2 || got.WhenToUse() != "何时用" || got.Invocation() != sdk.InvocationUserOnly {
|
||||
t.Fatalf("往返后 Version/WhenToUse/Invocation = %d/%q/%q", got.Version(), got.WhenToUse(), got.Invocation())
|
||||
}
|
||||
if len(got.AllowedTools()) != 1 || got.AllowedTools()[0] != "read" {
|
||||
t.Fatalf("AllowedTools = %v, want [read](丢了它等于放宽能力)", got.AllowedTools())
|
||||
}
|
||||
if len(got.DisallowedTools()) != 1 || got.DisallowedTools()[0] != "write" {
|
||||
t.Fatalf("DisallowedTools = %v, want [write]", got.DisallowedTools())
|
||||
}
|
||||
if got.ContextMode() != sdk.ContextFork || got.Agent() != "researcher" {
|
||||
t.Fatalf("Context/Agent = %q/%q, want fork/researcher", got.ContextMode(), got.Agent())
|
||||
}
|
||||
if got.Model() != "fast" || got.Effort() != "high" {
|
||||
t.Fatalf("Model/Effort = %q/%q, want fast/high", got.Model(), got.Effort())
|
||||
}
|
||||
if len(got.Triggers()) != 1 || len(got.Paths()) != 1 || got.Metadata()["owner"] != "yeiji" {
|
||||
t.Fatalf("Triggers/Paths/Metadata 往返失败:%v/%v/%v", got.Triggers(), got.Paths(), got.Metadata())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_基础Skill往返_缺省 断言只实现基础 sdk.Skill 的技能也能落盘,读回来是
|
||||
// 文档规定的缺省值(而不是零值)。
|
||||
func TestStore_基础Skill往返_缺省(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := s.Upsert(ctx, mockSkill{name: "plain"}); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
got, err := s.Get(ctx, "plain")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Version() != sdk.DefaultSkillVersion || got.Invocation() != sdk.InvocationBoth || got.ContextMode() != sdk.ContextInline {
|
||||
t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", got.Version(), got.Invocation(), got.ContextMode())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_SQLInjection_安全 存入形似 SQL 的名字/描述,验证值被原样往返(参数化
|
||||
// 语句)、表仍然完好、且不波及其它行。
|
||||
func TestStore_SQLInjection_安全(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件实现 design/09 §6.4-3 的「工具面 = 求交集(只收不放)」:
|
||||
//
|
||||
// 技能轮次可用工具 = allowed-tools(若声明)∩ 当前安全闸 ∩ 全局注册表
|
||||
// − disallowed-tools
|
||||
//
|
||||
// 🔴 铁律:**任何情况下都不得因为技能而放宽**。因此求值函数只从「注册表给出的
|
||||
// 全集」里**删**,绝不往里**加**;安全闸缺位(nil)时结果是空集(fail-closed)。
|
||||
//
|
||||
// 与 Claude Code 的刻意差异(见 design/09 §6.0 对照表):它的 allowed-tools 语义
|
||||
// 是「免审批」,明说 *It does not restrict which tools are available*;我们的
|
||||
// allowed-tools 是**可用工具的上限(子集)**,敏感/写操作仍需代审 + 审计。
|
||||
|
||||
// Gate 是当前回合的安全闸:actor 元数据 + `ToolPolicy` + 唤醒态允许集。
|
||||
// 它由运行层(loop)构造后注入 —— 技能包不自己判定身份,也不缓存判定结果。
|
||||
//
|
||||
// 实现必须幂等且并发安全(步骤并行时会同时被问)。
|
||||
type Gate interface {
|
||||
// Allow 报告名为 name 的工具在当前安全闸下是否可用。
|
||||
Allow(name string) bool
|
||||
}
|
||||
|
||||
// GateFunc 把普通函数适配成 Gate。
|
||||
type GateFunc func(name string) bool
|
||||
|
||||
// Allow 实现 Gate;nil 函数一律拒(fail-closed)。
|
||||
func (f GateFunc) Allow(name string) bool { return f != nil && f(name) }
|
||||
|
||||
// NameSet 是按名字集合判定的 Gate —— 唤醒态允许集的自然形态。
|
||||
type NameSet map[string]struct{}
|
||||
|
||||
// NewNameSet 把名字列表转成 NameSet(去空白、忽略空串)。
|
||||
func NewNameSet(names []string) NameSet {
|
||||
set := make(NameSet, len(names))
|
||||
for _, name := range names {
|
||||
if trimmed := strings.TrimSpace(name); trimmed != "" {
|
||||
set[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Allow 实现 Gate:只有显式在集合里的名字才可用。
|
||||
func (s NameSet) Allow(name string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := s[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// AllOf 把多个闸串成「全允许才允许」,用于把 actor 判定、ToolPolicy 判定与唤醒
|
||||
// 允许集叠在一个 Gate 上喂给 ResolveToolSurface。
|
||||
//
|
||||
// **没有任何一个闸(gates 全 nil/空)时一律全拒**:宁可什么都不给,也不给一个
|
||||
// 无人看守的工具面。
|
||||
func AllOf(gates ...Gate) Gate {
|
||||
effective := make([]Gate, 0, len(gates))
|
||||
for _, g := range gates {
|
||||
if g != nil {
|
||||
effective = append(effective, g)
|
||||
}
|
||||
}
|
||||
if len(effective) == 0 {
|
||||
return GateFunc(nil)
|
||||
}
|
||||
return GateFunc(func(name string) bool {
|
||||
for _, g := range effective {
|
||||
if !g.Allow(name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveToolSurface 计算技能轮次实际可用的工具名。
|
||||
//
|
||||
// 参数:
|
||||
// - registered:全局注册表给出的工具名全集(本轮 dispatcher 真正持有的工具);
|
||||
// - allowed:技能的 allowed-tools;**空表示技能不额外收窄**(仍受安全闸约束);
|
||||
// - disallowed:技能的 disallowed-tools,最后减掉;
|
||||
// - gate:当前安全闸;nil ⇒ 结果为空集(缺闸即全拒)。
|
||||
//
|
||||
// 结果按 registered 的顺序输出(去重、跳过空白名),因此调用方传入确定序就得到
|
||||
// 确定序 —— 函数本身不排序,避免打乱 dispatcher 的既有工具次序。
|
||||
func ResolveToolSurface(registered, allowed, disallowed []string, gate Gate) []string {
|
||||
if gate == nil || len(registered) == 0 {
|
||||
// 缺闸或没有注册表 ⇒ 没有任何工具可用。绝不「因为技能没声明上限就放行」。
|
||||
return nil
|
||||
}
|
||||
allowedSet := NewNameSet(allowed)
|
||||
disallowedSet := NewNameSet(disallowed)
|
||||
hasAllowed := len(allowedSet) > 0
|
||||
|
||||
out := make([]string, 0, len(registered))
|
||||
seen := make(map[string]struct{}, len(registered))
|
||||
for _, name := range registered {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[trimmed]; dup {
|
||||
continue
|
||||
}
|
||||
if hasAllowed {
|
||||
if _, ok := allowedSet[trimmed]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, blocked := disallowedSet[trimmed]; blocked {
|
||||
continue
|
||||
}
|
||||
if !gate.Allow(trimmed) {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SortedCopy 返回排序后的名字副本。需要确定序的调用方(如从 map 序的注册表读
|
||||
// 工具名)先用它归一,再交给 ResolveToolSurface。
|
||||
func SortedCopy(names []string) []string {
|
||||
out := append([]string(nil), names...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Validate 校验技能定义的结构约束(design/09 §6.2 的「校验(必做,错误可分类)」):
|
||||
//
|
||||
// - steps[].name 非空、不重复(重复 ⇒ ErrStepDuplicate);
|
||||
// - steps[].depends 指向存在的步骤(否则 ErrStepDependsMissing);
|
||||
// - 步骤依赖无环(有环 ⇒ ErrStepCycle,**加载期就拒,运行期绝不挂死**);
|
||||
// - allowed-tools 与 disallowed-tools 无同名(否则 ErrToolConflict);
|
||||
// - guardrails[].id 不重复(否则 ErrGuardrailDuplicate);
|
||||
// - invocation / context 取值合法(否则 sdk.ErrInvalidSkillField);
|
||||
// - paths 的 glob 语法合法(否则 sdk.ErrInvalidSkillField)。
|
||||
//
|
||||
// 加载器在解析后调用它(Load/LoadFile/LoadDir 都过这一关),Runner 在开工前
|
||||
// 再调用一次 —— 校验器对外部直接构造的 sdk.Skill 同样有效。
|
||||
func Validate(s sdk.Skill) error {
|
||||
if s == nil || strings.TrimSpace(s.Name()) == "" {
|
||||
return fmt.Errorf("skills: validate: %w", ErrInvalidSkill)
|
||||
}
|
||||
name := s.Name()
|
||||
spec := sdk.SkillView(s)
|
||||
if !spec.Invocation().Valid() {
|
||||
return fmt.Errorf("skills: validate %q: invocation %q: %w", name, spec.Invocation(), sdk.ErrInvalidSkillField)
|
||||
}
|
||||
if !spec.ContextMode().Valid() {
|
||||
return fmt.Errorf("skills: validate %q: context %q: %w", name, spec.ContextMode(), sdk.ErrInvalidSkillField)
|
||||
}
|
||||
if err := validatePaths(name, spec.Paths()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateToolDecls(name, spec.AllowedTools(), spec.DisallowedTools()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGuardrails(name, s.Guardrails()); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := TopoOrder(s.Steps())
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: validate %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePaths 检查每个 glob 的语法(用 OS 无关的 path.Match 做语法体检:它只
|
||||
// 报语法错,不做真匹配)。语法错会让「按工作文件自动激活」永远不命中,属于静默
|
||||
// 失效,因此在加载期就拒。
|
||||
func validatePaths(name string, patterns []string) error {
|
||||
for _, p := range patterns {
|
||||
if _, err := path.Match(p, ""); err != nil {
|
||||
return fmt.Errorf("skills: validate %q: paths %q: %w", name, p, sdk.ErrInvalidSkillField)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateToolDecls 拒绝「同一工具同时被允许与被禁止」的自相矛盾声明。
|
||||
func validateToolDecls(name string, allowed, disallowed []string) error {
|
||||
if len(allowed) == 0 || len(disallowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, tool := range allowed {
|
||||
allowedSet[strings.TrimSpace(tool)] = struct{}{}
|
||||
}
|
||||
for _, tool := range disallowed {
|
||||
if _, hit := allowedSet[strings.TrimSpace(tool)]; hit {
|
||||
return fmt.Errorf("skills: validate %q: tool %q: %w", name, tool, ErrToolConflict)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGuardrails 拒绝重复的 guardrail id(重复让「哪条 guardrail」说不清)。
|
||||
func validateGuardrails(name string, guardrails []sdk.Guardrail) error {
|
||||
seen := make(map[string]struct{}, len(guardrails))
|
||||
for _, g := range guardrails {
|
||||
id := strings.TrimSpace(g.ID)
|
||||
if _, dup := seen[id]; dup {
|
||||
return fmt.Errorf("skills: validate %q: guardrail %q: %w", name, g.ID, ErrGuardrailDuplicate)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopoOrder 返回按依赖拓扑排序后的步骤名。就绪步骤按**声明顺序**出队,因此输出
|
||||
// 是确定的(同样的定义永远得到同样的顺序)。有任何一步还没排进去就说明依赖成环,
|
||||
// 此时返回 ErrStepCycle 并如实列出未排入的步骤名。
|
||||
//
|
||||
// 步骤名重复、为空、或 depends 指向不存在的步骤,都会先在这里被拒。
|
||||
func TopoOrder(steps []sdk.Step) ([]string, error) {
|
||||
if len(steps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
index := make(map[string]int, len(steps))
|
||||
remaining := make([]int, len(steps))
|
||||
dependents := make([][]int, len(steps))
|
||||
for i, st := range steps {
|
||||
name := strings.TrimSpace(st.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("skills: topo order: step #%d has a blank name: %w", i, ErrInvalidSkill)
|
||||
}
|
||||
if _, dup := index[name]; dup {
|
||||
return nil, fmt.Errorf("skills: topo order: step %q: %w", name, ErrStepDuplicate)
|
||||
}
|
||||
index[name] = i
|
||||
}
|
||||
for i, st := range steps {
|
||||
for _, dep := range st.Depends {
|
||||
depIdx, ok := index[strings.TrimSpace(dep)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("skills: topo order: step %q depends on unknown step %q: %w",
|
||||
st.Name, dep, ErrStepDependsMissing)
|
||||
}
|
||||
remaining[i]++
|
||||
dependents[depIdx] = append(dependents[depIdx], i)
|
||||
}
|
||||
}
|
||||
queue := make([]int, 0, len(steps))
|
||||
for i := range steps {
|
||||
if remaining[i] == 0 {
|
||||
queue = append(queue, i)
|
||||
}
|
||||
}
|
||||
order := make([]string, 0, len(steps))
|
||||
for len(queue) > 0 {
|
||||
idx := queue[0]
|
||||
queue = queue[1:]
|
||||
order = append(order, steps[idx].Name)
|
||||
for _, dep := range dependents[idx] {
|
||||
remaining[dep]--
|
||||
if remaining[dep] == 0 {
|
||||
queue = append(queue, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(order) != len(steps) {
|
||||
return nil, fmt.Errorf("skills: topo order: steps %v: %w", unsortedStepNames(steps, remaining), ErrStepCycle)
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// unsortedStepNames 收集没排进拓扑序的步骤名(按声明序,错误信息因此确定)。
|
||||
func unsortedStepNames(steps []sdk.Step, remaining []int) []string {
|
||||
out := make([]string, 0, len(steps))
|
||||
for i, st := range steps {
|
||||
if remaining[i] > 0 {
|
||||
out = append(out, st.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+192
-25
@@ -1,55 +1,222 @@
|
||||
package sdk
|
||||
|
||||
// Step is one unit of a Skill's reusable workflow.
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidSkillField 表示技能定义里的某个字段取值非法:枚举越界,或声明自相
|
||||
// 矛盾(例如同一个工具同时出现在 allowed-tools 与 disallowed-tools)。
|
||||
// ParseInvocation / ParseContextMode 用它分类错误,调用方用 errors.Is 判定。
|
||||
var ErrInvalidSkillField = errors.New("sdk: invalid skill field")
|
||||
|
||||
// Step 是技能工作流里的一步(design/09 §6.2)。
|
||||
type Step struct {
|
||||
// Name is a short identifier for the step.
|
||||
// Name 是步骤标识,技能内唯一;depends 按它引用。
|
||||
Name string
|
||||
// Prompt is the instruction the model follows for this step.
|
||||
// Prompt 是这一步交给模型的指令。
|
||||
Prompt string
|
||||
// Depends lists the Names of steps that must finish before this step runs.
|
||||
// Depends 列出必须先完成的前序步骤名。
|
||||
Depends []string
|
||||
}
|
||||
|
||||
// Ref is a reference a Skill may pull in for extra context or shared tooling.
|
||||
// Ref 是技能可以引用的外部素材(工具、文档、文件等)。
|
||||
type Ref struct {
|
||||
// Name identifies the reference.
|
||||
// Name 标识该引用。
|
||||
Name string
|
||||
// Type is a free-form kind, for example "tool", "doc" or "file".
|
||||
// Type 是自由取值的种类,例如 "tool"、"doc"、"file"。
|
||||
Type string
|
||||
// Value is the reference payload or pointer.
|
||||
// Value 是引用载荷或指针。
|
||||
Value string
|
||||
// Optional reports whether the reference may be absent without failing.
|
||||
// Optional 为真表示该引用缺失不算失败。
|
||||
Optional bool
|
||||
}
|
||||
|
||||
// Guardrail is a constraint a Skill applies while running.
|
||||
// Guardrail 是技能执行期间施加的约束(design/09 §6.4-4)。
|
||||
type Guardrail struct {
|
||||
// ID names the guardrail.
|
||||
// ID 命名该约束。
|
||||
ID string
|
||||
// Message is the rule text enforced on the workflow.
|
||||
// Message 是约束内容(也是违规时如实回显的那一句)。
|
||||
Message string
|
||||
// Enforced reports whether a violation aborts the Skill (true) or is only a
|
||||
// warning (false).
|
||||
// Enforced 为真表示违反即中止技能;为假表示只 WARN 后继续。
|
||||
Enforced bool
|
||||
}
|
||||
|
||||
// Skill is a reusable, higher-order workflow: an ordered set of steps paired
|
||||
// with prompts, references and guardrails. It is more structured than a Tool
|
||||
// and typically composes several steps.
|
||||
// Skill 是可复用的高阶工作流:一组带提示词的步骤,配以基础提示词、引用与约束。
|
||||
// 它比 Tool 更结构化,通常由多个步骤组合而成。
|
||||
//
|
||||
// Implementations must be safe for concurrent use.
|
||||
// 实现必须并发安全。
|
||||
type Skill interface {
|
||||
// Name returns the unique skill identifier.
|
||||
// Name 返回技能的唯一标识。
|
||||
Name() string
|
||||
// Description explains what the Skill accomplishes; the model uses it to
|
||||
// select a Skill for a task.
|
||||
// Description 说明技能做什么;模型据此挑选技能。
|
||||
Description() string
|
||||
// Prompt returns the base system prompt for the Skill.
|
||||
// Prompt 返回技能的基础系统提示词。
|
||||
Prompt() string
|
||||
// Steps returns the ordered workflow steps.
|
||||
// Steps 返回有序的工作流步骤。
|
||||
Steps() []Step
|
||||
// Refs returns the references the Skill may draw on.
|
||||
// Refs 返回技能可引用的素材。
|
||||
Refs() []Ref
|
||||
// Guardrails returns the constraints the Skill enforces.
|
||||
// Guardrails 返回技能施加的约束。
|
||||
Guardrails() []Guardrail
|
||||
}
|
||||
|
||||
// Invocation 是技能的调用三态(design/09 §6.2 定稿,对齐 Agent Skills 的
|
||||
// `disable-model-invocation` / `user-invocable` 两枚开关压成的一枚枚举)。
|
||||
type Invocation string
|
||||
|
||||
const (
|
||||
// InvocationBoth:清单含 description,人与模型都可调(缺省)。
|
||||
InvocationBoth Invocation = "both"
|
||||
// InvocationModelOnly:清单**不含** description(省上下文),只有模型可调。
|
||||
InvocationModelOnly Invocation = "model-only"
|
||||
// InvocationUserOnly:清单含 description,**模型不可调**(只认显式调用)。
|
||||
InvocationUserOnly Invocation = "user-only"
|
||||
)
|
||||
|
||||
// Valid 报告 i 是否是已知的调用态;空值不算合法(缺省由 OrDefault 补)。
|
||||
func (i Invocation) Valid() bool {
|
||||
switch i {
|
||||
case InvocationBoth, InvocationModelOnly, InvocationUserOnly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OrDefault 把空值补成 InvocationBoth(design/09 §6.2 的缺省列)。
|
||||
func (i Invocation) OrDefault() Invocation {
|
||||
if i == "" {
|
||||
return InvocationBoth
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// ModelInvocable 报告模型是否可以调用该技能(user-only 不可调)。
|
||||
func (i Invocation) ModelInvocable() bool { return i.OrDefault() != InvocationUserOnly }
|
||||
|
||||
// UserInvocable 报告人是否可以显式调用该技能(model-only 不给人调)。
|
||||
func (i Invocation) UserInvocable() bool { return i.OrDefault() != InvocationModelOnly }
|
||||
|
||||
// ListingHasDescription 报告该调用态下清单是否要带 description
|
||||
// (model-only 不带:清单只给名字,省上下文)。
|
||||
func (i Invocation) ListingHasDescription() bool { return i.OrDefault() != InvocationModelOnly }
|
||||
|
||||
// ParseInvocation 解析磁盘/YAML 上的调用态字符串。空串取缺省 both;其余非法值
|
||||
// 一律报错(绝不静默当成 both —— 那是把配置写错伪装成成功)。
|
||||
func ParseInvocation(s string) (Invocation, error) {
|
||||
trimmed := Invocation(strings.TrimSpace(s))
|
||||
if trimmed == "" {
|
||||
return InvocationBoth, nil
|
||||
}
|
||||
if !trimmed.Valid() {
|
||||
return "", fmt.Errorf("sdk: invocation %q: %w", s, ErrInvalidSkillField)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// ContextMode 是技能的上下文模式(design/09 §6.2/§6.4-6)。
|
||||
type ContextMode string
|
||||
|
||||
const (
|
||||
// ContextInline:在当前会话上下文内跑(缺省)。
|
||||
ContextInline ContextMode = "inline"
|
||||
// ContextFork:在隔离子代理里跑,不看会话历史,结论回灌父会话。
|
||||
ContextFork ContextMode = "fork"
|
||||
)
|
||||
|
||||
// Valid 报告 c 是否是已知的上下文模式;空值不算合法。
|
||||
func (c ContextMode) Valid() bool {
|
||||
switch c {
|
||||
case ContextInline, ContextFork:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OrDefault 把空值补成 ContextInline(缺省)。
|
||||
func (c ContextMode) OrDefault() ContextMode {
|
||||
if c == "" {
|
||||
return ContextInline
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseContextMode 解析磁盘/YAML 上的上下文模式。空串取缺省 inline;其余非法值
|
||||
// 报错(理由同 ParseInvocation)。
|
||||
func ParseContextMode(s string) (ContextMode, error) {
|
||||
trimmed := ContextMode(strings.TrimSpace(s))
|
||||
if trimmed == "" {
|
||||
return ContextInline, nil
|
||||
}
|
||||
if !trimmed.Valid() {
|
||||
return "", fmt.Errorf("sdk: context %q: %w", s, ErrInvalidSkillField)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// SkillSpec 是技能定义的完整只读视图(design/09 §6.2 定稿字段)。
|
||||
//
|
||||
// 它是 Skill 之上的**可选**扩展:未实现它的技能一律按各字段的缺省取值看待,
|
||||
// 因此既有的第三方 Skill 实现不需要改动即可继续注册与运行。
|
||||
type SkillSpec interface {
|
||||
Skill
|
||||
// Version 是定义版本(形状迁移用),缺省 1。
|
||||
Version() int
|
||||
// WhenToUse 是「何时使用」补充说明,与 Description 一起进清单并计入预算。
|
||||
WhenToUse() string
|
||||
// Invocation 是调用三态。
|
||||
Invocation() Invocation
|
||||
// Triggers 是触发词,与 Paths 一起决定自动激活。
|
||||
Triggers() []string
|
||||
// Paths 是 glob;仅当工作文件命中时自动激活。
|
||||
Paths() []string
|
||||
// AllowedTools 是**能力上限**(不是免审批名单),见 design/09 §6.4-3。
|
||||
AllowedTools() []string
|
||||
// DisallowedTools 从上限里剔除。
|
||||
DisallowedTools() []string
|
||||
// ContextMode 是上下文模式(对应 YAML 键 `context`;方法名不叫 Context 以
|
||||
// 避免与标准库 context 混淆)。
|
||||
ContextMode() ContextMode
|
||||
// Agent 是 context: fork 时使用的子代理类型。
|
||||
Agent() string
|
||||
// Model 是可选模型覆盖;per-role 路由优先于它(design/04 §3)。
|
||||
Model() string
|
||||
// Effort 是可选思考档位覆盖。
|
||||
Effort() string
|
||||
// Metadata 是自由 KV,宿主不解释。
|
||||
Metadata() map[string]string
|
||||
}
|
||||
|
||||
// SkillView 把任意 Skill 归一化成 SkillSpec:实现了 SkillSpec 的原样返回,
|
||||
// 否则套一层全缺省的包装。宿主用它读技能字段,就不必到处写类型断言。
|
||||
func SkillView(s Skill) SkillSpec {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if spec, ok := s.(SkillSpec); ok {
|
||||
return spec
|
||||
}
|
||||
return plainSkill{Skill: s}
|
||||
}
|
||||
|
||||
// plainSkill 为只实现基础 Skill 的技能补齐缺省字段。
|
||||
type plainSkill struct{ Skill }
|
||||
|
||||
func (p plainSkill) Version() int { return DefaultSkillVersion }
|
||||
func (p plainSkill) WhenToUse() string { return "" }
|
||||
func (p plainSkill) Invocation() Invocation { return InvocationBoth }
|
||||
func (p plainSkill) Triggers() []string { return nil }
|
||||
func (p plainSkill) Paths() []string { return nil }
|
||||
func (p plainSkill) AllowedTools() []string { return nil }
|
||||
func (p plainSkill) DisallowedTools() []string { return nil }
|
||||
func (p plainSkill) ContextMode() ContextMode { return ContextInline }
|
||||
func (p plainSkill) Agent() string { return "" }
|
||||
func (p plainSkill) Model() string { return "" }
|
||||
func (p plainSkill) Effort() string { return "" }
|
||||
func (p plainSkill) Metadata() map[string]string { return nil }
|
||||
|
||||
// DefaultSkillVersion 是未声明 version 时的定义版本(design/09 §6.2 缺省列)。
|
||||
const DefaultSkillVersion = 1
|
||||
Reference in new issue
Block a user