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