feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)

- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件);
  新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/
  context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死);
  depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w)
- Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点
  (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播
- 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools
- 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定)

⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:35 +08:00
1 parent 29181245ce
commit f75e7a9c9b
17 files changed
+3067 -164

No files matched your search

+117
View File
@@ -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
}
+124
View File
@@ -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
}
+166 -26
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -13,19 +14,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// Loader loads Skill definitions from YAML — a single stream (Load), a file // 技能定义的磁盘文件名(目录式)。目录式优先于单文件式(design/09 §6.1)。
// (LoadFile), or a directory of configs/skills/*.yaml files (LoadDir). It does const skillDefinitionFile = "skill.yaml"
// not persist; persist via Store or publish via Register.
// 目录式技能除 skill.yaml 之外的文件(支持文件)不由加载器读入 —— 它们只作
// 只读引用素材,由调用方按 Loaded.Dir 解析(design/09 §6.5)。
// Loader 从 YAML 加载技能定义:单流(Load)、单文件(LoadFile)、或
// configs/skills 目录(LoadDir / LoadDirDetailed)。它只读盘、不落库:需要
// 持久化走 Store,需要发布走 Register。
type Loader struct { type Loader struct {
log *slog.Logger log *slog.Logger
} }
// NewLoader builds a Loader. log may be nil. // NewLoader 构造 Loader。log 可以为 nil。
func NewLoader(log *slog.Logger) *Loader { func NewLoader(log *slog.Logger) *Loader {
return &Loader{log: sourcedLog(log)} return &Loader{log: sourcedLog(log)}
} }
// skillYAML is the on-disk YAML shape of a skill. // skillYAML 是技能定义的磁盘 YAML 形状(design/09 §6.2 定稿字段)。
type skillYAML struct { type skillYAML struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Description string `yaml:"description"` Description string `yaml:"description"`
@@ -33,6 +40,21 @@ type skillYAML struct {
Steps []yamlStep `yaml:"steps"` Steps []yamlStep `yaml:"steps"`
Refs []yamlRef `yaml:"refs"` Refs []yamlRef `yaml:"refs"`
Guardrails []yamlGuardrail `yaml:"guardrails"` 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 { type yamlStep struct {
@@ -54,8 +76,22 @@ type yamlGuardrail struct {
Enforced bool `yaml:"enforced"` Enforced bool `yaml:"enforced"`
} }
// Load parses one skill from r. It returns ErrInvalidSkill when the document has // Loaded 是一份技能定义及其磁盘来源。Dir 让调用方能够解析目录式技能的**支持
// a blank name. // 文件**(只读引用素材)。
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) { func (l *Loader) Load(r io.Reader) (*Skill, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("skills: loader: load: %w", ErrInvalidSkill) 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 { if err != nil {
return nil, err 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 return skill, nil
} }
// LoadFile loads the single skill YAML file at path. // LoadFile 加载 path 指向的单份技能定义文件。
func (l *Loader) LoadFile(path string) (*Skill, error) { func (l *Loader) LoadFile(path string) (*Skill, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
@@ -86,30 +126,116 @@ func (l *Loader) LoadFile(path string) (*Skill, error) {
return skill, nil return skill, nil
} }
// LoadDir loads every *.yaml/*.yml skill file in dir, in readdir order. A file // LoadDir 加载 dir 下的全部技能定义,返回解析并校验后的技能(按技能 id 升序)。
// that fails to parse stops the load. Non-YAML files are skipped. // 任一文件解码/校验失败即整体失败(fail fast),绝不半加载。
func (l *Loader) LoadDir(dir string) ([]*Skill, error) { func (l *Loader) LoadDir(dir string) ([]*Skill, error) {
entries, err := os.ReadDir(dir) loaded, err := l.LoadDirDetailed(dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err) return nil, err
} }
out := make([]*Skill, 0, len(entries)) out := make([]*Skill, 0, len(loaded))
for _, e := range entries { for _, item := range loaded {
if e.IsDir() || !isYAML(e.Name()) { out = append(out, item.Skill)
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)
} }
return out, nil 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) { 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)) steps := make([]sdk.Step, 0, len(d.Steps))
for _, st := range d.Steps { for _, st := range d.Steps {
steps = append(steps, sdk.Step{Name: st.Name, Prompt: st.Prompt, Depends: st.Depends}) 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 { for _, g := range d.Guardrails {
guardrails = append(guardrails, sdk.Guardrail{ID: g.ID, Message: g.Message, Enforced: g.Enforced}) 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 { if err != nil {
return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err) return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err)
} }
return skill, nil return skill, nil
} }
// isYAML 报告文件名是否是 YAML(.yaml/.yml,大小写不敏感)。
func isYAML(name string) bool { func isYAML(name string) bool {
lower := strings.ToLower(name) lower := strings.ToLower(name)
return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") 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)
}
+259 -26
View File
@@ -4,16 +4,15 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"strings" "strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// Workflow is a Skill expanded into its executable form: the base system prompt // Workflow 是一个展开后的技能:基础系统提示词 + 有序步骤 + 引用 + 约束,
// plus the ordered steps, references, and guardrails, ready to be handed to the // 可以直接交给执行侧。Runner.Expand 只做展开(不改数据),真执行走 RunSkill。
// loop/agent executor (M2). The Runner only assembles workflows; it does not
// execute commands.
type Workflow struct { type Workflow struct {
Name string Name string
Description string Description string
@@ -21,25 +20,139 @@ type Workflow struct {
Steps []sdk.Step Steps []sdk.Step
Refs []sdk.Ref Refs []sdk.Ref
Guardrails []sdk.Guardrail Guardrails []sdk.Guardrail
// Spec 是 design/09 §6.2 定稿的新增字段(含归一化缺省值)。
Spec Spec
} }
// Runner expands skills into workflows. It is the M5 placeholder for the // GuardrailFinalSubject 是收尾全量复核时传给 GuardrailChecker 的 subject:它表示
// skill-execution wrapper: expansion (the real deliverable) is wired now, while // 「本次校验的对象是**全部步骤的累计输出**」,而不是某一步。步骤名不可能取到这个
// Run awaits M12/loop integration. // 值(它以 $ 开头,且步骤名还会被 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 { type Runner struct {
reg *registry.Registry reg *registry.Registry
log *slog.Logger log *slog.Logger
} }
// NewRunner builds a Runner over the given registry, used by ExpandByName to // NewRunner 在 reg 之上构造 Runner;reg 供 ExpandByName / Run 按名查找技能用,
// look up registered skills. log may be nil. // 也可以为 nil(此时只能用 RunSkill 直接执行)。log 可以为 nil。
func NewRunner(reg *registry.Registry, log *slog.Logger) *Runner { func NewRunner(reg *registry.Registry, log *slog.Logger) *Runner {
return &Runner{reg: reg, log: sourcedLog(log)} return &Runner{reg: reg, log: sourcedLog(log)}
} }
// Expand converts s into a Workflow, copying the step, ref, and guardrail slices // Expand 把 s 展开成 Workflow,并复制步骤/引用/约束切片(调用方拥有结果)。
// so the caller owns the result. It returns ErrInvalidSkill when s is nil or has // s 为 nil 或名字为空时返回 ErrInvalidSkill。
// a blank name.
func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) { func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return Workflow{}, fmt.Errorf("skills: runner: expand: %w", err) 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()) == "" { if s == nil || strings.TrimSpace(s.Name()) == "" {
return Workflow{}, fmt.Errorf("skills: runner: expand: %w", ErrInvalidSkill) return Workflow{}, fmt.Errorf("skills: runner: expand: %w", ErrInvalidSkill)
} }
spec := sdk.SkillView(s)
wf := Workflow{ wf := Workflow{
Name: s.Name(), Name: s.Name(),
Description: s.Description(), Description: s.Description(),
@@ -54,33 +168,152 @@ func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
Steps: cloneSteps(s.Steps()), Steps: cloneSteps(s.Steps()),
Refs: cloneRefs(s.Refs()), Refs: cloneRefs(s.Refs()),
Guardrails: cloneGuardrails(s.Guardrails()), 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 return wf, nil
} }
// ExpandByName looks up a skill in the registry by name and expands it. It // ExpandByName 按名从注册表找技能并展开。Runner 没有注册表时返回
// returns ErrNilRegistrar when the runner has no registry, the registry's error // ErrNilRegistrar;名字不存在时返回注册表的 registry.ErrNotFound;注册表里该名字
// (registry.ErrNotFound) when the name is absent, and ErrNotFound when the // 不是技能时返回 ErrNotFound。
// registered value is not a Skill.
func (r *Runner) ExpandByName(ctx context.Context, name string) (Workflow, error) { 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 { 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) cap, err := r.reg.Get(name)
if err != nil { 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) skill, ok := cap.Value.(sdk.Skill)
if !ok { 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 // toolSurface 求本次执行的工具面:allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools。
// lands in the loop/agent executor (M2) and the full wiring (M12); it always func (r *Runner) toolSurface(ctx context.Context, s sdk.Skill, opts RunOptions) ([]string, error) {
// returns ErrRunPending. if err := ctx.Err(); err != nil {
func (r *Runner) Run(_ context.Context, _ string, _ any) (any, error) { return nil, fmt.Errorf("skills: runner: run %q: %w", s.Name(), err)
return nil, ErrRunPending }
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(),
})
} }
+223 -5
View File
@@ -3,9 +3,12 @@ package skills
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync"
"testing" "testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
func TestRunner_Expand_正常(t *testing.T) { 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" { if len(wf.Guardrails) != 1 || wf.Guardrails[0].ID != "g1" {
t.Fatalf("Guardrails = %+v, want one guardrail g1", wf.Guardrails) 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) { func TestRunner_Expand_Nil_异常(t *testing.T) {
@@ -80,7 +86,7 @@ func TestRunner_ExpandByName_正常(t *testing.T) {
func TestRunner_ExpandByName_NotSkill_异常(t *testing.T) { func TestRunner_ExpandByName_NotSkill_异常(t *testing.T) {
t.Parallel() t.Parallel()
reg := registry.New() 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 { if err := reg.Register(registry.Capability{Name: "notaskill", Kind: registry.KindTool, Value: "x"}); err != nil {
t.Fatalf("register tool-cap: %v", err) 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() t.Parallel()
r := NewRunner(registry.New(), testLogger()) skill := mustSkill(t, "brief",
if _, err := r.Run(context.Background(), "review", nil); !errors.Is(err, ErrRunPending) { []sdk.Step{
t.Fatalf("Run err = %v, want ErrRunPending", err) {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]...)
}
+191 -46
View File
@@ -1,18 +1,20 @@
// Package skills implements the M5 reusable-workflow capability: a Skill is a // Package skills 实现 M5 的可复用工作流能力:一个 Skill 是比 Tool 更结构化、
// higher-order, persistable workflow — an ordered set of steps plus a base // 且可持久化的「高阶工作流」——一组带提示词的步骤(含依赖关系),配以基础
// prompt, references, and guardrails — that builds on pkg/plugins/sdk and is // 提示词、引用与约束(DevDocs/design/09 §0/§3/§6)。
// more structured than a Tool (DevDocs/design/09 §0/§3).
// //
// The package provides five pieces: // 包内五件套:
// //
// - Skill: the concrete in-memory model implementing pkg/plugins/sdk.Skill. // - Skill:实现 pkg/plugins/sdk.Skill(SkillSpec) 的具象内存模型。
// - Store: persistence of skills in a SQLite skills table. // - Store:把技能定义持久化进 SQLite 的 skills 表。
// - Loader: loading Skill definitions from YAML (a single stream or a // - Loader:从 YAML 加载定义(单流 / 单文件 / configs/skills 目录),并做
// configs/skills/*.yaml directory). // 加载期校验(依赖缺失、依赖环、allowed/disallowed 冲突等,见 Validate)。
// - Runner: expanding a Skill into an executable Workflow for the loop/agent // - Runner:把技能展开成可执行工作流(Expand),并**真执行**(Run/RunSkill):
// executor (the actual execution itself lands in M2/M12). // 步骤拓扑调度 + 无依赖步骤并行(上限 2,硬上限 3)+ guardrail 强制点。
// - Register: publishing skills to the global capability registry // - Register:把技能作为 KindSkill 能力发布进全局能力注册表
// (internal/capability/registry) as KindSkill capabilities. // (internal/capability/registry)。
//
// 能力收缩(design/09 §6.4-3)见 surface.go:技能轮次可用工具 = allowed-tools
// ∩ 安全闸 ∩ 全局注册表 − disallowed-tools,**任何情况下不因技能放宽**。
package skills package skills
import ( import (
@@ -26,29 +28,47 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// Sentinel errors returned by the skills package. Use errors.Is to test. // 本包的错误哨兵。调用方一律用 errors.Is 分类,绝不靠字符串匹配。
var ( var (
// ErrInvalidSkill is returned when a skill is nil or has a blank name. // ErrInvalidSkill 表示技能为 nil 或名字为空。
ErrInvalidSkill = errors.New("skills: invalid skill") 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") ErrNotFound = errors.New("skills: skill not found")
// ErrNilRegistrar is returned by Register/NewRunner when no registry was // ErrNilRegistrar 由 Register/NewRunner 在未提供注册表时返回。
// supplied.
ErrNilRegistrar = errors.New("skills: nil registrar") 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") ErrNilDB = errors.New("skills: nil db")
// ErrRunPending is returned by Runner.Run, which awaits M12 wiring. // ErrStepDuplicate 表示步骤名重复(依赖图会因此歧义,故拒绝)。
ErrRunPending = errors.New("skills: run pending") 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): // Skill 是技能的可持久化具象形态(对应 pkg/plugins/sdk.Skill):一组带依赖的
// a reusable workflow (ordered steps with dependencies), a base prompt, // 有序步骤 + 基础提示词 + 引用 + 约束,外加 design/09 §6.2 定稿的新增字段
// references, and guardrails. It is more structured than a Tool and is the // (版本、调用三态、触发词/路径、工具上限、上下文模式、模型覆盖、元数据)。
// higher-order capability described in DevDocs/design/09 §0.
// //
// Skill is immutable after construction; its getters return copies of the slice // Skill 构造后不再变化;所有 getter 都返回切片/映射的副本,因此 *Skill 可以按
// fields, so a *Skill is safe for concurrent use as the sdk.Skill contract // sdk.Skill 的契约要求并发使用。
// requires.
type Skill struct { type Skill struct {
name string name string
description string description string
@@ -56,53 +76,178 @@ type Skill struct {
steps []sdk.Step steps []sdk.Step
refs []sdk.Ref refs []sdk.Ref
guardrails []sdk.Guardrail guardrails []sdk.Guardrail
spec Spec
} }
// compile-time assertion that *Skill satisfies the SDK Skill interface. // 编译期断言:*Skill 同时满足基础 sdk.Skill 与完整的 sdk.SkillSpec。
var _ sdk.Skill = (*Skill)(nil) var (
_ sdk.Skill = (*Skill)(nil)
_ sdk.SkillSpec = (*Skill)(nil)
)
// New builds a Skill. It returns ErrInvalidSkill when name is blank. The step, // Spec 是 design/09 §6.2 定稿的新增字段集合。零值即「全部取缺省」:
// ref, and guardrail slices are copied, so the caller remains free to mutate // version=1、invocation=both、context=inline,其余为空。
// them afterwards. //
func New(name, description, prompt string, steps []sdk.Step, refs []sdk.Ref, guardrails []sdk.Guardrail) (*Skill, error) { // 它同时是 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) == "" { if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("skills: new: %w", ErrInvalidSkill) return nil, fmt.Errorf("skills: new: %w", ErrInvalidSkill)
} }
return &Skill{ s := &Skill{
name: name, name: name,
description: description, description: description,
prompt: prompt, prompt: prompt,
steps: cloneSteps(steps), steps: cloneSteps(steps),
refs: cloneRefs(refs), refs: cloneRefs(refs),
guardrails: cloneGuardrails(guardrails), 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 } func (s *Skill) Name() string { return s.name }
// Description explains what the Skill accomplishes; the model uses it to select // Description 说明技能做什么;模型据此挑选技能。
// a Skill for a task.
func (s *Skill) Description() string { return s.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 } 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) } 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) } 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) } 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 { func cloneSteps(in []sdk.Step) []sdk.Step {
if len(in) == 0 { if len(in) == 0 {
return nil return nil
} }
out := make([]sdk.Step, len(in)) 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 return out
} }
@@ -124,8 +269,8 @@ func cloneGuardrails(in []sdk.Guardrail) []sdk.Guardrail {
return out return out
} }
// sourcedLog returns a source-tagged logger suitable for this package, falling // sourcedLog 返回带来源标签(source=capability/skills)的日志器;l 为 nil 时
// back to a discard logger when l is nil so callers can pass nil safely. // 换成丢弃输出的日志器,让叶子调用方可以安全传 nil。
func sourcedLog(l *slog.Logger) *slog.Logger { func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil { if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil)) l = slog.New(slog.NewTextHandler(io.Discard, nil))
+377
View File
@@ -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
}
+367
View File
@@ -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)
}
}
}
+90 -33
View File
@@ -15,18 +15,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// Store persists skill definitions in SQLite (the skills table). It is safe for // Store 把技能定义持久化进 SQLite 的 skills 表。它并发安全(底层 *sql.DB 串行化
// concurrent use — the underlying *sql.DB serializes writes — and every query is // 写),且所有查询都参数化 —— 值永远不拼进 SQL。
// parameterized, so values are never interpolated into SQL.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
} }
// createSkillsTable creates the skills schema. id is an opaque primary key; // createSkillsTable 创建 skills 表结构。id 是不透明主键;name 是稳定且唯一的技能
// name is the stable, unique skill identifier used for Upsert/Get/Delete // 标识(Upsert/Get/Delete 都按它查)。desc 列加双引号是因为 DESC 在排序语境里是
// lookups. The desc column is double-quoted because DESC is a SQL keyword in // SQL 关键字。spec_json 承载 design/09 §6.2 的新增字段(版本/调用三态/工具上限/
// ordering context. // 上下文模式/元数据等)——**必须落盘**:丢掉 allowed-tools 等于把能力收缩放宽,
// 那是安全红线,不允许「读回来就变成不限」。
const createSkillsTable = ` const createSkillsTable = `
CREATE TABLE IF NOT EXISTS skills ( CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -36,37 +36,44 @@ CREATE TABLE IF NOT EXISTS skills (
steps_json TEXT NOT NULL DEFAULT '[]', steps_json TEXT NOT NULL DEFAULT '[]',
refs_json TEXT NOT NULL DEFAULT '[]', refs_json TEXT NOT NULL DEFAULT '[]',
guardrails_json TEXT NOT NULL DEFAULT '[]', guardrails_json TEXT NOT NULL DEFAULT '[]',
spec_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_name ON skills (name); CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_name ON skills (name);
` `
// upsertSkillSQL inserts a skill, updating the existing row when the name // upsertSkillSQL 插入一条技能;name 已存在时更新该行,created_at 保持不变。
// already exists; created_at is preserved across an update.
const upsertSkillSQL = ` const upsertSkillSQL = `
INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, created_at, updated_at) INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, spec_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
"desc" = excluded."desc", "desc" = excluded."desc",
prompt = excluded.prompt, prompt = excluded.prompt,
steps_json = excluded.steps_json, steps_json = excluded.steps_json,
refs_json = excluded.refs_json, refs_json = excluded.refs_json,
guardrails_json = excluded.guardrails_json, guardrails_json = excluded.guardrails_json,
spec_json = excluded.spec_json,
updated_at = excluded.updated_at updated_at = excluded.updated_at
` `
// skillColumns 是读取技能行时的列清单(禁止 SELECT *)。
const skillColumns = `name, "desc", prompt, steps_json, refs_json, guardrails_json, spec_json`
const ( const (
getSkillSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills WHERE name = ?` getSkillSQL = `SELECT ` + skillColumns + ` FROM skills WHERE name = ?`
listSkillsSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills ORDER BY name` listSkillsSQL = `SELECT ` + skillColumns + ` FROM skills ORDER BY name`
deleteSkillSQL = `DELETE FROM skills WHERE 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 const idByteLen = 16
// NewStore builds a Store, creating the skills table if needed. It returns // specColumnName 是 spec_json 列名;ensureSkillSpecColumn 用它做幂等补列。
// ErrNilDB when db is nil. ctx is used for the schema creation statement. const specColumnName = "spec_json"
// NewStore 构造 Store,并在需要时建表/补列。db 为 nil 时返回 ErrNilDB。
// ctx 用于建表语句。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil { if db == nil {
return nil, fmt.Errorf("skills: store: %w", ErrNilDB) 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 { if _, err := db.ExecContext(ctx, createSkillsTable); err != nil {
return nil, fmt.Errorf("skills: store: create skills table: %w", err) 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") s.log.Info("skills table ready")
return s, nil return s, nil
} }
// Upsert stores skill, inserting a new row or updating the existing row that // ensureSpecColumn 给既有的 skills 表补上 spec_json 列(SQLite 支持 ADD COLUMN 但
// shares its name. It returns ErrInvalidSkill when skill is nil or has a blank // 不支持 IF NOT EXISTS,因此先读 pragma 再决定)。CREATE TABLE IF NOT EXISTS 对旧库
// name. // 不会改结构,缺这一列就会让写入报错,故必须幂等补齐。
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 { func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
if skill == nil || strings.TrimSpace(skill.Name()) == "" { if skill == nil || strings.TrimSpace(skill.Name()) == "" {
return fmt.Errorf("skills: store: upsert: %w", ErrInvalidSkill) 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 { if err != nil {
return fmt.Errorf("skills: store: upsert %q: guardrails: %w", name, err) 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) now := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := s.db.ExecContext(ctx, upsertSkillSQL, if _, err := s.db.ExecContext(ctx, upsertSkillSQL,
newID(), name, skill.Description(), skill.Prompt(), newID(), name, skill.Description(), skill.Prompt(),
stepsJSON, refsJSON, guardrailsJSON, now, now, stepsJSON, refsJSON, guardrailsJSON, specJSON, now, now,
); err != nil { ); err != nil {
return fmt.Errorf("skills: store: upsert %q: %w", name, err) 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 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) { func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
row := s.db.QueryRowContext(ctx, getSkillSQL, name) row := s.db.QueryRowContext(ctx, getSkillSQL, name)
skill, err := scanSkill(row, name) skill, err := scanSkill(row, name)
@@ -120,7 +163,7 @@ func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
return skill, nil return skill, nil
} }
// List returns every stored skill, ordered by name. // List 返回全部已存技能,按名字升序。
func (s *Store) List(ctx context.Context) ([]*Skill, error) { func (s *Store) List(ctx context.Context) ([]*Skill, error) {
rows, err := s.db.QueryContext(ctx, listSkillsSQL) rows, err := s.db.QueryContext(ctx, listSkillsSQL)
if err != nil { if err != nil {
@@ -142,8 +185,7 @@ func (s *Store) List(ctx context.Context) ([]*Skill, error) {
return out, nil return out, nil
} }
// Delete removes the skill named name. It returns ErrNotFound when no such skill // Delete 删除名为 name 的技能;不存在时返回 ErrNotFound。
// exists.
func (s *Store) Delete(ctx context.Context, name string) error { func (s *Store) Delete(ctx context.Context, name string) error {
res, err := s.db.ExecContext(ctx, deleteSkillSQL, name) res, err := s.db.ExecContext(ctx, deleteSkillSQL, name)
if err != nil { if err != nil {
@@ -160,14 +202,13 @@ func (s *Store) Delete(ctx context.Context, name string) error {
return nil 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 } type rowScanner interface{ Scan(dest ...any) error }
// scanSkill reads one skills row and builds a *Skill. key is the lookup name // scanSkill 读一行 skills 记录并构造 *Skill。key 只用于错误文案里的查找名。
// used only in error messages.
func scanSkill(sc rowScanner, key string) (*Skill, error) { func scanSkill(sc rowScanner, key string) (*Skill, error) {
var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON string var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON, specJSON string
if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON); err != nil { if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON, &specJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("skills: store: get %q: %w", key, ErrNotFound) 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 { if err != nil {
return nil, fmt.Errorf("skills: store: skill %q: guardrails: %w", name, err) 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 { if err != nil {
return nil, fmt.Errorf("skills: store: skill %q: %w", name, err) 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 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 { func newID() string {
var b [idByteLen]byte var b [idByteLen]byte
if _, err := rand.Read(b[:]); err != nil { if _, err := rand.Read(b[:]); err != nil {
// crypto/rand is effectively infallible; fall back to a nanosecond // crypto/rand 实际上不会失败;退化成纳秒时间戳,别让存储因为拿不到 id 卡住。
// timestamp so the store never blocks on a missing identifier.
return fmt.Sprintf("%d", time.Now().UnixNano()) return fmt.Sprintf("%d", time.Now().UnixNano())
} }
return hex.EncodeToString(b[:]) return hex.EncodeToString(b[:])
+71 -3
View File
@@ -150,9 +150,77 @@ func TestStore_Upsert_Invalid_异常(t *testing.T) {
} }
} }
// TestStore_SQLInjection_安全 stores a name/description that looks like SQL and // TestStore_Spec往返_正常 断言 design/09 §6.2 的新增字段真的落盘并能原样读回 ——
// verifies the values round-trip verbatim (parameterized statements), that the // 尤其是 allowed-tools:读回来变成「不限」就等于把能力收缩放宽,那是安全红线,
// table survives, and that unrelated rows are unaffected. // 所以这里逐字段钉死。
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) { func TestStore_SQLInjection_安全(t *testing.T) {
t.Parallel() t.Parallel()
s := newTestStore(t) s := newTestStore(t)
+135
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+159
View File
@@ -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
View File
@@ -1,55 +1,222 @@
package sdk 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 { type Step struct {
// Name is a short identifier for the step. // Name 是步骤标识,技能内唯一;depends 按它引用。
Name string Name string
// Prompt is the instruction the model follows for this step. // Prompt 是这一步交给模型的指令。
Prompt string Prompt string
// Depends lists the Names of steps that must finish before this step runs. // Depends 列出必须先完成的前序步骤名。
Depends []string Depends []string
} }
// Ref is a reference a Skill may pull in for extra context or shared tooling. // Ref 是技能可以引用的外部素材(工具、文档、文件等)。
type Ref struct { type Ref struct {
// Name identifies the reference. // Name 标识该引用。
Name string Name string
// Type is a free-form kind, for example "tool", "doc" or "file". // Type 是自由取值的种类,例如 "tool"、"doc"、"file"。
Type string Type string
// Value is the reference payload or pointer. // Value 是引用载荷或指针。
Value string Value string
// Optional reports whether the reference may be absent without failing. // Optional 为真表示该引用缺失不算失败。
Optional bool Optional bool
} }
// Guardrail is a constraint a Skill applies while running. // Guardrail 是技能执行期间施加的约束(design/09 §6.4-4)。
type Guardrail struct { type Guardrail struct {
// ID names the guardrail. // ID 命名该约束。
ID string ID string
// Message is the rule text enforced on the workflow. // Message 是约束内容(也是违规时如实回显的那一句)。
Message string Message string
// Enforced reports whether a violation aborts the Skill (true) or is only a // Enforced 为真表示违反即中止技能;为假表示只 WARN 后继续。
// warning (false).
Enforced bool Enforced bool
} }
// Skill is a reusable, higher-order workflow: an ordered set of steps paired // Skill 是可复用的高阶工作流:一组带提示词的步骤,配以基础提示词、引用与约束。
// with prompts, references and guardrails. It is more structured than a Tool // 它比 Tool 更结构化,通常由多个步骤组合而成。
// and typically composes several steps.
// //
// Implementations must be safe for concurrent use. // 实现必须并发安全。
type Skill interface { type Skill interface {
// Name returns the unique skill identifier. // Name 返回技能的唯一标识。
Name() string Name() string
// Description explains what the Skill accomplishes; the model uses it to // Description 说明技能做什么;模型据此挑选技能。
// select a Skill for a task.
Description() string Description() string
// Prompt returns the base system prompt for the Skill. // Prompt 返回技能的基础系统提示词。
Prompt() string Prompt() string
// Steps returns the ordered workflow steps. // Steps 返回有序的工作流步骤。
Steps() []Step Steps() []Step
// Refs returns the references the Skill may draw on. // Refs 返回技能可引用的素材。
Refs() []Ref Refs() []Ref
// Guardrails returns the constraints the Skill enforces. // Guardrails 返回技能施加的约束。
Guardrails() []Guardrail 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