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

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

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

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
package skills
import (
"path"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件实现 design/09 §6.2 里 `triggers` 与 `paths` 两枚字段的**判定语义**:
// 它们一起决定技能是否**自动激活**(对齐 Agent Skills 的 `paths` 限定自动激活)。
//
// 判定本身是纯函数,真正的「激活 + 注入正文」落在 loop 片(design/09 §6.3)。
// MatchPaths 报告 filePaths 里是否有文件命中 patterns 里的任一 glob。
// patterns 为空 ⇒ false(没声明就不自动激活,而不是「任意文件都命中」)。
func MatchPaths(patterns, filePaths []string) bool {
for _, pattern := range patterns {
for _, file := range filePaths {
if MatchGlob(pattern, file) {
return true
}
}
}
return false
}
// MatchGlob 按 glob 语义匹配一个路径。
//
// 语义(刻意写明,避免与别处的 glob 库猜来猜去):
//
// - 路径分隔符统一成 `/`,Windows 的 `\` 会被归一;
// - `*` / `?` / `[...]` 用 path.Match 的单段语义,**不跨目录分隔符**;
// - `**` 匹配**零到多段**(如 `src/**/*.ts` 命中 `src/a/b.ts` 与 `src/b.ts`);
// - 模式里不含 `/` 时,额外按**文件名**匹配一次(写 `*.go` 的人想的是「任何
// .go 文件」,而不是「根目录下的 .go 文件」)。
//
// 语法非法的模式一律返回 false(加载期 Validate 已经拒过它们)。
func MatchGlob(pattern, file string) bool {
pattern = normalizePath(pattern)
file = normalizePath(file)
if pattern == "" || file == "" {
return false
}
if globSegments(strings.Split(pattern, "/"), strings.Split(file, "/")) {
return true
}
if !strings.Contains(pattern, "/") {
base := file
if idx := strings.LastIndex(file, "/"); idx >= 0 {
base = file[idx+1:]
}
// 模式不含 /:额外按文件名匹配一次(写 *.go 的人想的是「任何 .go 文件」)。
// 这里直接走段匹配 —— 再调 MatchGlob 会拿着同样的无斜杠模式无限递归。
return globSegments(strings.Split(pattern, "/"), []string{base})
}
return false
}
// globSegments 递归匹配已切段的两条路径,`**` 可吃掉零到多段。
func globSegments(pattern, name []string) bool {
if len(pattern) == 0 {
return len(name) == 0
}
if pattern[0] == "**" {
for i := 0; i <= len(name); i++ {
if globSegments(pattern[1:], name[i:]) {
return true
}
}
return false
}
if len(name) == 0 {
return false
}
ok, err := path.Match(pattern[0], name[0])
if err != nil || !ok {
return false
}
return globSegments(pattern[1:], name[1:])
}
// MatchTriggers 报告文本里是否出现任一触发词(大小写不敏感的子串匹配)。
// triggers 为空 ⇒ false。
func MatchTriggers(triggers []string, text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, trigger := range triggers {
trimmed := strings.ToLower(strings.TrimSpace(trigger))
if trimmed != "" && strings.Contains(lower, trimmed) {
return true
}
}
return false
}
// AutoActivates 报告技能是否因当前回合的文本/工作文件而**自动激活**:
// 触发词命中**或**工作文件命中 paths。两者都没声明 ⇒ 不自动激活(只能显式调用)。
func AutoActivates(s sdk.Skill, text string, workFiles []string) bool {
if s == nil {
return false
}
spec := sdk.SkillView(s)
triggers, paths := spec.Triggers(), spec.Paths()
if len(triggers) == 0 && len(paths) == 0 {
return false
}
return MatchTriggers(triggers, text) || MatchPaths(paths, workFiles)
}
// normalizePath 统一分隔符并去掉首尾空白;不做其它解释(不做绝对路径归一,
// 判定只关心「模式是否覆盖这个文件」)。
func normalizePath(p string) string {
return strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
}
@@ -0,0 +1,104 @@
package skills
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件专测 design/09 §6.2 的 triggers / paths 判定语义(自动激活)。
func TestMatchGlob_表驱动(t *testing.T) {
t.Parallel()
cases := []struct {
pattern string
file string
want bool
}{
{"src/**/*.ts", "src/a.ts", true},
{"src/**/*.ts", "src/a/b.ts", true},
{"src/**/*.ts", "test/a.ts", false},
{"**/*.go", "/repo/internal/x.go", true},
{"*.go", "internal/x.go", true}, // 模式不含 /:额外按文件名匹配
{"*.go", "internal/x.ts", false}, // 扩展名不符
{"internal/*.go", "internal/x.go", true},
{"internal/*.go", "internal/sub/x.go", false}, // * 不跨目录分隔符
{"**", "anything/at/all", true},
{"internal\\x.go", "internal/x.go", true}, // Windows 反斜杠归一
{"", "x.go", false},
{"x.go", "", false},
{"[bad", "x.go", false}, // 语法非法的模式一律 false
}
for _, tc := range cases {
tc := tc
t.Run(tc.pattern+"|"+tc.file, func(t *testing.T) {
t.Parallel()
if got := MatchGlob(tc.pattern, tc.file); got != tc.want {
t.Fatalf("MatchGlob(%q, %q) = %v, want %v", tc.pattern, tc.file, got, tc.want)
}
})
}
}
func TestMatchPaths_语义(t *testing.T) {
t.Parallel()
if MatchPaths(nil, []string{"a.go"}) {
t.Fatal("未声明 paths 时不该命中")
}
if MatchPaths([]string{"*.go"}, nil) {
t.Fatal("没有工作文件时不该命中")
}
if !MatchPaths([]string{"*.md", "*.go"}, []string{"notes.txt", "main.go"}) {
t.Fatal("任一模式命中任一文件即应命中")
}
}
func TestMatchTriggers_大小写不敏感(t *testing.T) {
t.Parallel()
if MatchTriggers(nil, "帮我写简报") {
t.Fatal("未声明 triggers 时不该命中")
}
if !MatchTriggers([]string{"简报", " 汇总 "}, "帮我写一份简报") {
t.Fatal("触发词应当命中(含去空白)")
}
if !MatchTriggers([]string{"BRIEF"}, "make a brief") {
t.Fatal("触发词应当大小写不敏感")
}
if MatchTriggers([]string{"简报"}, "") {
t.Fatal("空文本不该命中")
}
}
func TestAutoActivates_语义(t *testing.T) {
t.Parallel()
if AutoActivates(nil, "x", nil) {
t.Fatal("nil 技能不该自动激活")
}
plain := mustSpecSkill(t, "plain", "d", "", sdk.InvocationBoth)
if AutoActivates(plain, "简报", []string{"x.go"}) {
t.Fatal("未声明 triggers/paths 时不该自动激活(只能显式调用)")
}
triggered := mustTriggerSkill(t, "t", []string{"简报"}, nil)
if !AutoActivates(triggered, "给我一份简报", nil) {
t.Fatal("触发词命中即应自动激活")
}
if AutoActivates(triggered, "无关的话", nil) {
t.Fatal("触发词没命中时不该自动激活")
}
pathed := mustTriggerSkill(t, "p", nil, []string{"src/**/*.go"})
if !AutoActivates(pathed, "", []string{"src/a/b.go"}) {
t.Fatal("paths 命中即应自动激活")
}
if AutoActivates(pathed, "", []string{"docs/a.md"}) {
t.Fatal("paths 没命中时不该自动激活")
}
}
func mustTriggerSkill(t *testing.T, name string, triggers, paths []string) *Skill {
t.Helper()
skill, err := New(name, "d", "p", nil, nil, nil, WithSpec(Spec{Triggers: triggers, Paths: paths}))
if err != nil {
t.Fatalf("New(%s): %v", name, err)
}
return skill
}
+124
View File
@@ -0,0 +1,124 @@
package skills
import (
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件实现 design/09 §6.3 的**清单投影**:模型在清单里只看到 name +
// description(+when-to-use),**正文(prompt / 步骤渲染文本)不进清单**。正文只在
// 技能命中后才展开注入(注入点走 design/14 的唤醒式口径,落在 loop 片)。
//
// 这里只做「投影 + 预算 + 三态」这一层纯计算,不读盘、不写上下文。
const (
// SkillListingDescriptionBudget 是单条 description(+when-to-use) 的字符上限
// (design/09 §6.3-2:照 Agent Skills listing 的 1536 字符上限)。
SkillListingDescriptionBudget = 1536
// SkillListingMaxEntries 是清单条数的全局上限。超出时**如实标 truncated**,
// 绝不静默丢条目(design/09 §6.3-2)。
SkillListingMaxEntries = 64
// SkillListingTruncatedMarker 是超预算时追加的显式标记。
SkillListingTruncatedMarker = "…(truncated)"
)
// ListingEntry 是清单里的一行。它只带名字与描述 —— 正文不在其中。
type ListingEntry struct {
// Name 是技能名。
Name string
// Description 是 description 与 when-to-use 拼成的一句说明(按预算截断);
// invocation=model-only 时为空(该调用态下清单不带描述,省上下文)。
Description string
// ModelInvocable 报告模型是否可以调用该技能(invocation=user-only 为 false)。
ModelInvocable bool
// UserInvocable 报告人是否可以显式调用该技能(invocation=model-only 为 false)。
UserInvocable bool
// Truncated 报告本行描述是否被预算截断。
Truncated bool
}
// Listing 是清单投影的结果。
type Listing struct {
// Entries 是清单条目(保持输入顺序)。
Entries []ListingEntry
// Truncated 报告条目数是否因 SkillListingMaxEntries 被截断。
Truncated bool
// Note 是截断的人类可读说明(未截断时为空)。
Note string
}
// BuildListing 把技能列表投影成模型可见的清单:三态过滤 + 单条预算 + 条数上限。
//
// 三态(design/09 §6.3-4,与 Agent Skills 的矩阵逐格对齐):
//
// - both ⇒ 清单含 description,人与模型都可调;
// - model-only ⇒ 清单**不含** description,只有模型能调;
// - user-only ⇒ 清单含 description,**模型不可调**。
//
// nil 技能被跳过。**所有**技能都会出现在清单里(user-only 的技能只是模型不可调,
// 但仍要让人在面板里看得到),因此条数上限对三类一视同仁。
func BuildListing(list []sdk.Skill) Listing {
out := Listing{}
limit := SkillListingMaxEntries
for _, s := range list {
if s == nil {
continue
}
if len(out.Entries) >= limit {
out.Truncated = true
break
}
out.Entries = append(out.Entries, listingEntry(s))
}
if out.Truncated {
out.Note = "skill listing truncated to " + strconv.Itoa(limit) + " entries"
}
return out
}
// listingEntry 投影一个技能。
func listingEntry(s sdk.Skill) ListingEntry {
invocation := sdk.SkillView(s).Invocation().OrDefault()
entry := ListingEntry{
Name: s.Name(),
ModelInvocable: invocation.ModelInvocable(),
UserInvocable: invocation.UserInvocable(),
}
if !invocation.ListingHasDescription() {
return entry
}
text := strings.TrimSpace(joinDescription(s.Description(), sdk.SkillView(s).WhenToUse()))
entry.Description, entry.Truncated = truncateBudget(text, SkillListingDescriptionBudget)
return entry
}
// joinDescription 把 description 与 when-to-use 合成清单里那一句(两者都空则为空)。
func joinDescription(description, whenToUse string) string {
parts := make([]string, 0, 2)
if trimmed := strings.TrimSpace(description); trimmed != "" {
parts = append(parts, trimmed)
}
if trimmed := strings.TrimSpace(whenToUse); trimmed != "" {
parts = append(parts, "Use when: "+trimmed)
}
return strings.Join(parts, " ")
}
// truncateBudget 按 rune 截断到 n 个字符以内,并报告是否真的截断了(截断标记也
// 计入预算,保证结果长度不超过 n)。
func truncateBudget(s string, n int) (string, bool) {
if n <= 0 {
return s, false
}
runes := []rune(s)
if len(runes) <= n {
return s, false
}
marker := []rune(SkillListingTruncatedMarker)
if n <= len(marker) {
return string(runes[:n]), true
}
return string(runes[:n-len(marker)]) + SkillListingTruncatedMarker, true
}
@@ -0,0 +1,98 @@
package skills
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件专测 design/09 §6.3 的清单投影:只有 name + description(+when-to-use)、
// 三态过滤、单条预算、条数上限**如实标 truncated**。
func TestBuildListing_三态(t *testing.T) {
t.Parallel()
both := mustSpecSkill(t, "both", "做 A", "在做 A 时", sdk.InvocationBoth)
modelOnly := mustSpecSkill(t, "model-only", "做 B", "", sdk.InvocationModelOnly)
userOnly := mustSpecSkill(t, "user-only", "做 C", "", sdk.InvocationUserOnly)
listing := BuildListing([]sdk.Skill{both, modelOnly, userOnly})
if len(listing.Entries) != 3 {
t.Fatalf("条目数 = %d, want 3(user-only 也要在清单里可见)", len(listing.Entries))
}
if got := listing.Entries[0].Description; got != "做 A Use when: 在做 A 时" {
t.Fatalf("both 描述 = %q, want 拼接 description + when-to-use", got)
}
if !listing.Entries[0].ModelInvocable || !listing.Entries[0].UserInvocable {
t.Fatalf("both 应当人与模型都可调:%+v", listing.Entries[0])
}
if listing.Entries[1].Description != "" || !listing.Entries[1].ModelInvocable || listing.Entries[1].UserInvocable {
t.Fatalf("model-only 应当「清单不带描述、只有模型可调」:%+v", listing.Entries[1])
}
if listing.Entries[2].Description == "" || listing.Entries[2].ModelInvocable || !listing.Entries[2].UserInvocable {
t.Fatalf("user-only 应当「清单带描述、模型不可调」:%+v", listing.Entries[2])
}
}
// TestBuildListing_正文不进清单 断言渐进披露的硬规格:清单里没有正文(prompt /
// 步骤文本)—— 上面那些字段是唯一进清单的东西。
func TestBuildListing_正文不进清单(t *testing.T) {
t.Parallel()
skill := mustSpecSkill(t, "s", "描述", "", sdk.InvocationBoth)
listing := BuildListing([]sdk.Skill{skill})
if len(listing.Entries) != 1 {
t.Fatalf("条目数 = %d, want 1", len(listing.Entries))
}
if strings.Contains(listing.Entries[0].Description, "base prompt") {
t.Fatalf("正文泄漏进清单:%q", listing.Entries[0].Description)
}
}
func TestBuildListing_单条预算截断(t *testing.T) {
t.Parallel()
skill := mustSpecSkill(t, "long", strings.Repeat("描", SkillListingDescriptionBudget+500), "", sdk.InvocationBoth)
entry := BuildListing([]sdk.Skill{skill}).Entries[0]
if !entry.Truncated {
t.Fatal("超预算的描述必须被标 Truncated")
}
if got := len([]rune(entry.Description)); got != SkillListingDescriptionBudget {
t.Fatalf("截断后长度 = %d, want %d", got, SkillListingDescriptionBudget)
}
if !strings.HasSuffix(entry.Description, SkillListingTruncatedMarker) {
t.Fatalf("截断没有显式标记:%q", entry.Description[len(entry.Description)-20:])
}
}
func TestBuildListing_条数上限如实标(t *testing.T) {
t.Parallel()
list := make([]sdk.Skill, 0, SkillListingMaxEntries+3)
for i := 0; i < SkillListingMaxEntries+3; i++ {
list = append(list, mustSpecSkill(t, "s"+string(rune('a'+i%26))+string(rune('0'+i/26)), "d", "", sdk.InvocationBoth))
}
listing := BuildListing(list)
if len(listing.Entries) != SkillListingMaxEntries {
t.Fatalf("条目数 = %d, want %d", len(listing.Entries), SkillListingMaxEntries)
}
if !listing.Truncated || listing.Note == "" {
t.Fatalf("超上限必须如实标 Truncated 且给出说明:%+v", listing)
}
}
func TestBuildListing_跳过nil(t *testing.T) {
t.Parallel()
listing := BuildListing([]sdk.Skill{nil, mustSpecSkill(t, "s", "d", "", sdk.InvocationBoth), nil})
if len(listing.Entries) != 1 || listing.Truncated {
t.Fatalf("Listing = %+v, want 1 条且未截断", listing)
}
}
// mustSpecSkill 构造一个带 description/when-to-use/invocation 的技能。
func mustSpecSkill(t *testing.T, name, description, whenToUse string, invocation sdk.Invocation) *Skill {
t.Helper()
skill, err := New(name, description, "base prompt", nil, nil, nil,
WithSpec(Spec{WhenToUse: whenToUse, Invocation: invocation}))
if err != nil {
t.Fatalf("New(%s): %v", name, err)
}
return skill
}
+166 -26
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -13,19 +14,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Loader loads Skill definitions from YAML — a single stream (Load), a file
// (LoadFile), or a directory of configs/skills/*.yaml files (LoadDir). It does
// not persist; persist via Store or publish via Register.
// 技能定义的磁盘文件名(目录式)。目录式优先于单文件式(design/09 §6.1)。
const skillDefinitionFile = "skill.yaml"
// 目录式技能除 skill.yaml 之外的文件(支持文件)不由加载器读入 —— 它们只作
// 只读引用素材,由调用方按 Loaded.Dir 解析(design/09 §6.5)。
// Loader 从 YAML 加载技能定义:单流(Load)、单文件(LoadFile)、或
// configs/skills 目录(LoadDir / LoadDirDetailed)。它只读盘、不落库:需要
// 持久化走 Store,需要发布走 Register。
type Loader struct {
log *slog.Logger
}
// NewLoader builds a Loader. log may be nil.
// NewLoader 构造 Loader。log 可以为 nil。
func NewLoader(log *slog.Logger) *Loader {
return &Loader{log: sourcedLog(log)}
}
// skillYAML is the on-disk YAML shape of a skill.
// skillYAML 是技能定义的磁盘 YAML 形状(design/09 §6.2 定稿字段)。
type skillYAML struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
@@ -33,6 +40,21 @@ type skillYAML struct {
Steps []yamlStep `yaml:"steps"`
Refs []yamlRef `yaml:"refs"`
Guardrails []yamlGuardrail `yaml:"guardrails"`
// 以下为 design/09 §6.2 新增字段。字符串键一律小写连字符,对齐 Agent Skills
// 的既有惯例。枚举先按字符串收下,再经 sdk.Parse* 校验(非法值直接拒)。
Version int `yaml:"version"`
WhenToUse string `yaml:"when-to-use"`
Invocation string `yaml:"invocation"`
Triggers []string `yaml:"triggers"`
Paths []string `yaml:"paths"`
AllowedTools []string `yaml:"allowed-tools"`
DisallowedTools []string `yaml:"disallowed-tools"`
Context string `yaml:"context"`
Agent string `yaml:"agent"`
Model string `yaml:"model"`
Effort string `yaml:"effort"`
Metadata map[string]string `yaml:"metadata"`
}
type yamlStep struct {
@@ -54,8 +76,22 @@ type yamlGuardrail struct {
Enforced bool `yaml:"enforced"`
}
// Load parses one skill from r. It returns ErrInvalidSkill when the document has
// a blank name.
// Loaded 是一份技能定义及其磁盘来源。Dir 让调用方能够解析目录式技能的**支持
// 文件**(只读引用素材)。
type Loaded struct {
// ID 是磁盘上的技能 id(目录名,或单文件去掉扩展名的文件名)。
ID string
// Dir 是技能目录(目录式)或定义文件所在目录(单文件式)。
Dir string
// File 是实际读到的定义文件路径。
File string
// Skill 是解析并校验后的技能。
Skill *Skill
}
// Load 从 r 读一份技能定义。名字为空返回 ErrInvalidSkill;YAML 解码失败返回
// 解码错误;结构约束不满足返回 Validate 的可分类错误(ErrStepDependsMissing /
// ErrStepCycle / ErrToolConflict / ...)。
func (l *Loader) Load(r io.Reader) (*Skill, error) {
if r == nil {
return nil, fmt.Errorf("skills: loader: load: %w", ErrInvalidSkill)
@@ -68,11 +104,15 @@ func (l *Loader) Load(r io.Reader) (*Skill, error) {
if err != nil {
return nil, err
}
l.log.Info("skill loaded", "skill", skill.Name())
if err := Validate(skill); err != nil {
return nil, fmt.Errorf("skills: loader: %w", err)
}
l.log.Info("skill loaded",
"skill", skill.Name(), "steps", len(skill.Steps()), "invocation", string(skill.Invocation()))
return skill, nil
}
// LoadFile loads the single skill YAML file at path.
// LoadFile 加载 path 指向的单份技能定义文件。
func (l *Loader) LoadFile(path string) (*Skill, error) {
f, err := os.Open(path)
if err != nil {
@@ -86,30 +126,116 @@ func (l *Loader) LoadFile(path string) (*Skill, error) {
return skill, nil
}
// LoadDir loads every *.yaml/*.yml skill file in dir, in readdir order. A file
// that fails to parse stops the load. Non-YAML files are skipped.
// LoadDir 加载 dir 下的全部技能定义,返回解析并校验后的技能(按技能 id 升序)。
// 任一文件解码/校验失败即整体失败(fail fast),绝不半加载。
func (l *Loader) LoadDir(dir string) ([]*Skill, error) {
entries, err := os.ReadDir(dir)
loaded, err := l.LoadDirDetailed(dir)
if err != nil {
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err)
return nil, err
}
out := make([]*Skill, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !isYAML(e.Name()) {
continue
}
path := filepath.Join(dir, e.Name())
skill, err := l.LoadFile(path)
if err != nil {
return nil, fmt.Errorf("skills: loader: dir %q: %w", path, err)
}
out = append(out, skill)
out := make([]*Skill, 0, len(loaded))
for _, item := range loaded {
out = append(out, item.Skill)
}
return out, nil
}
// toSkill converts the YAML document into a Skill, validating the required name.
// LoadDirDetailed 与 LoadDir 相同,但额外返回每份定义的磁盘来源(id / 目录 /
// 文件),供调用方解析目录式技能的支持文件。
//
// 解析顺序(design/09 §6.1「先目录、后文件」):
//
// - 子目录 <id>/ 含 skill.yaml(或 skill.yml)⇒ 目录式,id = 目录名;
// - <id>.yaml / <id>.yml ⇒ 单文件式,id = 去扩展名的文件名;
// - 同一 id 两者都在 ⇒ **目录式胜出**,并记一条 WARN(不静默覆盖);
// - 子目录里没有 skill.yaml ⇒ 跳过(它可能是支持文件目录);
// - 其它文件(含 . 开头的隐藏文件)⇒ 跳过。
func (l *Loader) LoadDirDetailed(dir string) ([]Loaded, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("skills: loader: read dir %q: %w", dir, err)
}
byDir, byFile := scanSkillEntries(dir, entries)
ids := make([]string, 0, len(byDir)+len(byFile))
for id := range byDir {
ids = append(ids, id)
}
for id := range byFile {
if _, dup := byDir[id]; !dup {
ids = append(ids, id)
}
}
sort.Strings(ids)
out := make([]Loaded, 0, len(ids))
for _, id := range ids {
path, fromDir := byDir[id]
if !fromDir {
path = byFile[id]
} else if filePath, both := byFile[id]; both {
l.log.Warn("skill defined as both directory and file; directory wins",
"skill", id, "dir_file", path, "file", filePath)
}
skill, err := l.LoadFile(path)
if err != nil {
return nil, fmt.Errorf("skills: loader: dir %q: %w", dir, err)
}
out = append(out, Loaded{ID: id, Dir: filepath.Dir(path), File: path, Skill: skill})
}
return out, nil
}
// scanSkillEntries 把目录扫成两张表:目录式技能的 id→skill.yaml 路径,单文件式
// 技能的 id→文件路径。
func scanSkillEntries(dir string, entries []os.DirEntry) (byDir, byFile map[string]string) {
byDir = make(map[string]string)
byFile = make(map[string]string)
for _, e := range entries {
name := e.Name()
if e.IsDir() {
if path := definitionInDir(filepath.Join(dir, name)); path != "" {
byDir[name] = path
}
continue
}
if e.Type()&os.ModeSymlink != 0 {
// 符号链接不追(避免把目录外的定义悄悄引进来);如目标是普通文件,
// ReadDir 的 Type 会带 ModeSymlink,这里一律跳过并留给调用方显式加载。
continue
}
if strings.HasPrefix(name, ".") || !isYAML(name) {
continue
}
byFile[strings.TrimSuffix(name, filepath.Ext(name))] = filepath.Join(dir, name)
}
return byDir, byFile
}
// definitionInDir 返回子目录里的定义文件路径(skill.yaml 优先,其次 skill.yml);
// 都没有则返回空串。
func definitionInDir(dir string) string {
primary := filepath.Join(dir, skillDefinitionFile)
if info, err := os.Stat(primary); err == nil && info.Mode().IsRegular() {
return primary
}
alt := filepath.Join(dir, strings.TrimSuffix(skillDefinitionFile, ".yaml")+".yml")
if info, err := os.Stat(alt); err == nil && info.Mode().IsRegular() {
return alt
}
return ""
}
// toSkill 把 YAML 文档转成 Skill:枚举经 sdk.Parse* 校验(非法值直接拒),
// 其余字段原样搬运;结构约束由 Validate 负责。
func (d skillYAML) toSkill() (*Skill, error) {
invocation, err := sdk.ParseInvocation(d.Invocation)
if err != nil {
return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err)
}
contextMode, err := sdk.ParseContextMode(d.Context)
if err != nil {
return nil, fmt.Errorf("skills: loader: %q: %w", d.Name, err)
}
steps := make([]sdk.Step, 0, len(d.Steps))
for _, st := range d.Steps {
steps = append(steps, sdk.Step{Name: st.Name, Prompt: st.Prompt, Depends: st.Depends})
@@ -122,13 +248,27 @@ func (d skillYAML) toSkill() (*Skill, error) {
for _, g := range d.Guardrails {
guardrails = append(guardrails, sdk.Guardrail{ID: g.ID, Message: g.Message, Enforced: g.Enforced})
}
skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails)
skill, err := New(d.Name, d.Description, d.Prompt, steps, refs, guardrails, WithSpec(Spec{
Version: d.Version,
WhenToUse: d.WhenToUse,
Invocation: invocation,
Triggers: d.Triggers,
Paths: d.Paths,
AllowedTools: d.AllowedTools,
DisallowedTools: d.DisallowedTools,
Context: contextMode,
Agent: d.Agent,
Model: d.Model,
Effort: d.Effort,
Metadata: d.Metadata,
}))
if err != nil {
return nil, fmt.Errorf("skills: loader: invalid skill %q: %w", d.Name, err)
}
return skill, nil
}
// isYAML 报告文件名是否是 YAML(.yaml/.yml,大小写不敏感)。
func isYAML(name string) bool {
lower := strings.ToLower(name)
return strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml")
@@ -0,0 +1,244 @@
package skills
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// fullSkillYAML 覆盖 design/09 §6.2 的全部新增字段,用来断言「字段真的接上了」。
const fullSkillYAML = `
name: full
description: 全字段技能
prompt: base
version: 3
when-to-use: 当需要全字段校验时
invocation: model-only
triggers: [汇总, 简报]
paths: ["src/**/*.go"]
allowed-tools: [search, note]
disallowed-tools: [write]
context: fork
agent: researcher
model: fast
effort: high
metadata:
owner: yeiji
steps:
- name: one
prompt: p
`
func TestLoader_Load_新字段_正常(t *testing.T) {
t.Parallel()
sk, err := NewLoader(testLogger()).Load(strings.NewReader(fullSkillYAML))
if err != nil {
t.Fatalf("Load: %v", err)
}
if sk.Version() != 3 {
t.Fatalf("Version = %d, want 3", sk.Version())
}
if sk.WhenToUse() != "当需要全字段校验时" {
t.Fatalf("WhenToUse = %q", sk.WhenToUse())
}
if sk.Invocation() != sdk.InvocationModelOnly {
t.Fatalf("Invocation = %q, want model-only", sk.Invocation())
}
if got := strings.Join(sk.Triggers(), ","); got != "汇总,简报" {
t.Fatalf("Triggers = %q", got)
}
if got := strings.Join(sk.Paths(), ","); got != "src/**/*.go" {
t.Fatalf("Paths = %q", got)
}
if got := strings.Join(sk.AllowedTools(), ","); got != "search,note" {
t.Fatalf("AllowedTools = %q", got)
}
if got := strings.Join(sk.DisallowedTools(), ","); got != "write" {
t.Fatalf("DisallowedTools = %q", got)
}
if sk.ContextMode() != sdk.ContextFork || sk.Agent() != "researcher" {
t.Fatalf("Context/Agent = %q/%q, want fork/researcher", sk.ContextMode(), sk.Agent())
}
if sk.Model() != "fast" || sk.Effort() != "high" {
t.Fatalf("Model/Effort = %q/%q, want fast/high", sk.Model(), sk.Effort())
}
if got := sk.Metadata()["owner"]; got != "yeiji" {
t.Fatalf("Metadata[owner] = %q, want yeiji", got)
}
}
// TestLoader_Load_缺省值 断言未声明的字段落到文档规定的缺省(version=1 / both / inline)。
func TestLoader_Load_缺省值(t *testing.T) {
t.Parallel()
sk, err := NewLoader(testLogger()).Load(strings.NewReader("name: bare\nprompt: p\n"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if sk.Version() != sdk.DefaultSkillVersion || sk.Invocation() != sdk.InvocationBoth || sk.ContextMode() != sdk.ContextInline {
t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", sk.Version(), sk.Invocation(), sk.ContextMode())
}
if sk.AllowedTools() != nil || sk.Triggers() != nil || sk.Metadata() != nil {
t.Fatalf("未声明的集合字段应为空:%v/%v/%v", sk.AllowedTools(), sk.Triggers(), sk.Metadata())
}
}
// TestLoader_Load_非法枚举_异常 断言枚举写错是**显式失败**,不被静默当成缺省。
func TestLoader_Load_非法枚举_异常(t *testing.T) {
t.Parallel()
cases := map[string]string{
"invocation": "name: x\ninvocation: everyone\n",
"context": "name: x\ncontext: session\n",
"paths": "name: x\npaths: [\"[bad\"]\n",
}
for name, src := range cases {
src := src
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := NewLoader(testLogger()).Load(strings.NewReader(src)); !errors.Is(err, sdk.ErrInvalidSkillField) {
t.Fatalf("Load(%s) err = %v, want sdk.ErrInvalidSkillField", name, err)
}
})
}
}
// TestLoader_Load_结构校验_异常 覆盖 design/09 §6.2 要求的四类「加载期就拒」。
func TestLoader_Load_结构校验_异常(t *testing.T) {
t.Parallel()
cases := map[string]struct {
yaml string
want error
}{
"依赖不存在": {
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n",
want: ErrStepDependsMissing,
},
"依赖成环": {
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [b]\n - name: b\n prompt: p\n depends: [a]\n",
want: ErrStepCycle,
},
"自依赖": {
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n depends: [a]\n",
want: ErrStepCycle,
},
"工具自相矛盾": {
yaml: "name: x\nallowed-tools: [a, b]\ndisallowed-tools: [b]\n",
want: ErrToolConflict,
},
"护栏重复": {
yaml: "name: x\nguardrails:\n - id: g\n message: m\n - id: g\n message: n\n",
want: ErrGuardrailDuplicate,
},
"步骤重名": {
yaml: "name: x\nsteps:\n - name: a\n prompt: p\n - name: a\n prompt: p\n",
want: ErrStepDuplicate,
},
"步骤匿名": {
yaml: "name: x\nsteps:\n - prompt: p\n",
want: ErrInvalidSkill,
},
}
for name, tc := range cases {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := NewLoader(testLogger()).Load(strings.NewReader(tc.yaml))
if !errors.Is(err, tc.want) {
t.Fatalf("%s: err = %v, want %v", name, err, tc.want)
}
})
}
}
// TestLoader_LoadDir_目录式与单文件兼容 覆盖 design/09 §6.1 的载体规则:
// 目录式 <id>/skill.yaml、单文件 <id>.yaml、同 id 时目录胜出、无定义文件的子目录跳过。
func TestLoader_LoadDir_目录式与单文件兼容(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeNested(t, dir+"/brief/skill.yaml", "name: brief\nprompt: p\n")
writeNested(t, dir+"/brief/notes.md", "支持文件,不该被当成技能")
writeFile(t, dir+"/legacy.yaml", "name: legacy\nprompt: p\n")
writeFile(t, dir+"/both.yaml", "name: from-file\nprompt: p\n")
writeNested(t, dir+"/both/skill.yaml", "name: from-dir\nprompt: p\n")
writeNested(t, dir+"/asset/support.txt", "无 skill.yaml 的子目录")
writeFile(t, dir+"/.hidden.yaml", "name: hidden\nprompt: p\n")
loaded, err := NewLoader(testLogger()).LoadDirDetailed(dir)
if err != nil {
t.Fatalf("LoadDirDetailed: %v", err)
}
if len(loaded) != 3 {
t.Fatalf("加载了 %d 份,want 3(both/brief/legacy)", len(loaded))
}
byID := make(map[string]Loaded, len(loaded))
for _, item := range loaded {
byID[item.ID] = item
}
if got := byID["both"].Skill.Name(); got != "from-dir" {
t.Fatalf("同 id 目录与文件并存时取到 %q,want from-dir(目录胜出)", got)
}
if !strings.HasSuffix(filepath.ToSlash(byID["both"].File), "both/skill.yaml") {
t.Fatalf("both 的来源 = %q, want 目录式定义文件", byID["both"].File)
}
if !strings.HasSuffix(filepath.ToSlash(byID["brief"].Dir), "brief") {
t.Fatalf("brief 的 Dir = %q, want 指向技能目录(支持文件按它解析)", byID["brief"].Dir)
}
if !strings.HasSuffix(filepath.ToSlash(byID["legacy"].File), "legacy.yaml") {
t.Fatalf("legacy 的来源 = %q, want 单文件", byID["legacy"].File)
}
}
// TestLoader_LoadDir_坏定义即整体失败 断言目录加载是 fail fast:一份坏定义不会
// 被跳过(那会让「配置写错」看起来像「没这个技能」)。
func TestLoader_LoadDir_坏定义即整体失败(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeFile(t, dir+"/ok.yaml", "name: ok\nprompt: p\n")
writeFile(t, dir+"/bad.yaml", "name: bad\nsteps:\n - name: a\n prompt: p\n depends: [ghost]\n")
if _, err := NewLoader(testLogger()).LoadDir(dir); !errors.Is(err, ErrStepDependsMissing) {
t.Fatalf("LoadDir err = %v, want ErrStepDependsMissing", err)
}
}
// TestTopoOrder_顺序与环 断言拓扑序确定(就绪步骤按声明序出队),且成环时如实
// 列出没排进去的步骤。
func TestTopoOrder_顺序与环(t *testing.T) {
t.Parallel()
order, err := TopoOrder([]sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p", Depends: []string{"a"}},
{Name: "c", Prompt: "p", Depends: []string{"b", "a"}},
{Name: "z", Prompt: "p"},
{Name: "y", Prompt: "p", Depends: []string{"z"}},
})
if err != nil {
t.Fatalf("TopoOrder: %v", err)
}
if got := strings.Join(order, ","); got != "a,z,b,y,c" {
t.Fatalf("TopoOrder = %q, want a,z,b,y,c", got)
}
_, err = TopoOrder([]sdk.Step{
{Name: "a", Prompt: "p", Depends: []string{"b"}},
{Name: "b", Prompt: "p", Depends: []string{"a"}},
{Name: "free", Prompt: "p"},
})
if !errors.Is(err, ErrStepCycle) {
t.Fatalf("TopoOrder(cycle) err = %v, want ErrStepCycle", err)
}
if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") {
t.Fatalf("环错误没列出相关步骤:%v", err)
}
}
// writeNested 写文件并自动建父目录(目录式技能要的是 <id>/skill.yaml)。
func writeNested(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
writeFile(t, path, content)
}
+259 -26
View File
@@ -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(),
})
}
+223 -5
View File
@@ -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]...)
}
+191 -46
View File
@@ -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))
+377
View File
@@ -0,0 +1,377 @@
package skills
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 步骤调度器:把 design/09 §6.4-1/2/4/5 变成可测的行为。
//
// - 按 depends 拓扑调度;无依赖(含依赖已就绪)的步骤可并行;
// - 并行上限默认 2,硬上限 3(与 design/15 §2.5 的口径一致);
// - 每步输出后校验 guardrail:enforced 违反 ⇒ 中止并如实报「哪条、哪一步」,
// 非 enforced ⇒ WARN 后继续;
// - 全部步骤完成后再做一次**累计输出的全量复核**(design/09 §6.4-4 的「末步
// 前再全量复核一次」:并行调度下没有唯一的「末步」,因此落在全部步骤产出之后、
// 返回之前,复核对象是累计输出);
// - 任一步失败 ⇒ 技能失败(不静默跳过);父 ctx 取消 ⇒ 立即取消收敛。
const (
// defaultStepParallel 是未指定时的步骤并行上限。
defaultStepParallel = 2
// maxStepParallel 是并行上限的硬顶:技能调用方给再大的值也夹到这里。
maxStepParallel = 3
// maxStepOutputChars 是单步输出传给下游步骤时的字符上限。步骤输出按有界摘要
// 传递(design/09 §6.4-2),避免一步的长输出把后续步骤的上下文撑爆。
maxStepOutputChars = 4000
// stepTruncatedMarker 是步骤输出被预算截断时的显式标记(绝不静默截断)。
stepTruncatedMarker = "…(truncated)"
)
// clampParallel 把并行上限夹进 [1, maxStepParallel]:非正取默认 2,超硬顶夹到 3。
func clampParallel(n int) int {
if n <= 0 {
return defaultStepParallel
}
if n > maxStepParallel {
return maxStepParallel
}
return n
}
// runEnv 是一次技能执行的全部状态(每次 RunSkill 新建一个,因此没有跨执行的共享
// 可变状态)。只有调度器 goroutine 会读写它,步骤 goroutine 只读入参、经 channel 回传。
type runEnv struct {
skill sdk.Skill
steps []sdk.Step
spec Spec
tools []string
input string
exec StepExecutor
checker GuardrailChecker
parallel int
log *slog.Logger
}
// stepDone 是步骤 goroutine 回传给调度器的结果。
type stepDone struct {
index int
output string
err error
}
// stepGraph 是步骤依赖图:deps[i] 是步骤 i 依赖的索引,dependents 是反向边,
// remaining 是每个步骤还没完成的前序数,ready 是初始就绪队列(按声明序)。
type stepGraph struct {
deps [][]int
dependents [][]int
remaining []int
ready []int
}
// buildStepGraph 从已校验的步骤建图。名字重复、依赖缺失都会在此二次报错(Validate
// 已经拦过一次,这里是执行路径自己的保底)。
func buildStepGraph(steps []sdk.Step) (stepGraph, error) {
size := len(steps)
index := make(map[string]int, size)
for i, st := range steps {
index[strings.TrimSpace(st.Name)] = i
}
g := stepGraph{
deps: make([][]int, size),
dependents: make([][]int, size),
remaining: make([]int, size),
}
for i, st := range steps {
for _, dep := range st.Depends {
depIdx, ok := index[strings.TrimSpace(dep)]
if !ok {
return g, fmt.Errorf("skills: step %q depends on unknown step %q: %w",
st.Name, dep, ErrStepDependsMissing)
}
g.deps[i] = append(g.deps[i], depIdx)
g.dependents[depIdx] = append(g.dependents[depIdx], i)
g.remaining[i]++
}
}
for i := range steps {
if g.remaining[i] == 0 {
g.ready = append(g.ready, i)
}
}
return g, nil
}
// run 执行整个技能。base 里已带上技能名与工具面,本函数补上步骤结果与违规清单。
func (e *runEnv) run(ctx context.Context, base RunResult) (RunResult, error) {
e.steps = e.skill.Steps()
if len(e.steps) == 0 {
return e.result(base, nil, nil, nil), nil
}
graph, err := buildStepGraph(e.steps)
if err != nil {
return e.result(base, nil, nil, nil), fmt.Errorf("skills: runner: run %q: %w", base.Skill, err)
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
outcomes := make([]StepOutcome, len(e.steps))
outputs := make([]string, len(e.steps))
finished := make([]bool, len(e.steps))
doneCh := make(chan stepDone, e.parallel)
violations := make([]Violation, 0)
ready := append([]int(nil), graph.ready...)
running, completed := 0, 0
for completed < len(e.steps) {
if cerr := ctx.Err(); cerr != nil {
e.drain(doneCh, running)
return e.result(base, outcomes, finished, violations), e.wrapCanceled(base.Skill, cerr)
}
for running < e.parallel && len(ready) > 0 {
idx := ready[0]
ready = ready[1:]
running++
go e.execStep(ctx, idx, outputs, graph.deps[idx], doneCh)
}
if running == 0 {
// 已经没有在飞步骤、也没有就绪步骤,却仍有步骤没完成 ⇒ 依赖成环。
// Validate 已拦过一次,这里兜底返回而不是死等(运行期绝不挂死)。
return e.result(base, outcomes, finished, violations),
fmt.Errorf("skills: runner: run %q: %w", base.Skill, ErrStepCycle)
}
select {
case <-ctx.Done():
e.drain(doneCh, running)
return e.result(base, outcomes, finished, violations), e.wrapCanceled(base.Skill, ctx.Err())
case res := <-doneCh:
running--
if res.err != nil {
// 先取消(把信号发给在飞步骤),再收它们 —— 反过来会一直等到
// 各步自然结束,等于把失败当成了「等一下就好」。
cancel()
e.drain(doneCh, running)
return e.result(base, outcomes, finished, violations), res.err
}
outcomes[res.index] = StepOutcome{Name: e.steps[res.index].Name, Output: res.output}
outputs[res.index] = res.output
finished[res.index] = true
completed++
found, verr := e.checkStepGuardrails(ctx, e.steps[res.index].Name, res.output)
violations = append(violations, found...)
if verr != nil {
cancel()
e.drain(doneCh, running)
return e.result(base, outcomes, finished, violations), verr
}
e.release(graph, res.index, &ready)
}
}
found, verr := e.finalRecheck(ctx, outputs)
violations = append(violations, found...)
result := e.result(base, outcomes, finished, violations)
if verr != nil {
return result, verr
}
e.log.Info("skill run completed",
"skill", base.Skill, "steps", completed, "tools", len(base.Tools), "violations", len(violations))
return result, nil
}
// execStep 跑一步并把结果回传。它由调度器起在独立 goroutine 里,只读共享输入。
func (e *runEnv) execStep(ctx context.Context, index int, outputs []string, deps []int, done chan<- stepDone) {
step := e.steps[index]
if err := ctx.Err(); err != nil {
done <- stepDone{index: index, err: e.stepError(step.Name, err)}
return
}
req := StepRequest{
Skill: e.skill.Name(),
Step: step,
Prompt: joinPrompt(e.skill.Prompt(), step.Prompt),
Depends: e.depOutputs(deps, outputs),
Input: e.input,
Tools: append([]string(nil), e.tools...),
Refs: e.skill.Refs(),
Spec: e.spec,
}
res, err := e.exec.ExecuteStep(ctx, req)
if err != nil {
done <- stepDone{index: index, err: e.stepError(step.Name, err)}
return
}
done <- stepDone{index: index, output: res.Output}
}
// depOutputs 取本步依赖的各前序步骤输出(有界摘要),键为步骤名。
func (e *runEnv) depOutputs(deps []int, outputs []string) map[string]string {
if len(deps) == 0 {
return nil
}
out := make(map[string]string, len(deps))
for _, dep := range deps {
out[e.steps[dep].Name] = truncateOutput(outputs[dep], maxStepOutputChars)
}
return out
}
// release 把 step 完成的事实传播给它的后继:前序计数归零的后继进入就绪队列。
func (e *runEnv) release(graph stepGraph, step int, ready *[]int) {
for _, dependent := range graph.dependents[step] {
graph.remaining[dependent]--
if graph.remaining[dependent] == 0 {
*ready = append(*ready, dependent)
}
}
}
// checkStepGuardrails 在某步输出后校验全部 guardrail。返回发现的违规(非 enforced
// 的会一并记下),以及**必须中止**时的错误(enforced 违规、或校验器本身失败)。
func (e *runEnv) checkStepGuardrails(ctx context.Context, stepName, output string) ([]Violation, error) {
if e.checker == nil {
return nil, nil
}
found := make([]Violation, 0, len(e.skill.Guardrails()))
for _, g := range e.skill.Guardrails() {
violated, err := e.checker.Check(ctx, g, stepName, output)
if err != nil {
return found, fmt.Errorf("skills: runner: run %q: guardrail %q at step %q: %w",
e.skill.Name(), g.ID, stepName, fmt.Errorf("%w: %w", ErrGuardrailCheck, err))
}
if !violated {
continue
}
v := Violation{GuardrailID: g.ID, Subject: stepName, Message: g.Message, Enforced: g.Enforced}
found = append(found, v)
if g.Enforced {
return found, e.violationError(v)
}
e.log.Warn("skill guardrail violated (warn only)",
"skill", e.skill.Name(), "guardrail", g.ID, "step", stepName, "message", g.Message)
}
return found, nil
}
// finalRecheck 对**全部步骤的累计输出**做一次全量复核(design/09 §6.4-4)。
func (e *runEnv) finalRecheck(ctx context.Context, outputs []string) ([]Violation, error) {
guardrails := e.skill.Guardrails()
if e.checker == nil || len(guardrails) == 0 {
return nil, nil
}
joined := truncateOutput(strings.Join(nonEmptyOutputs(outputs, e.steps), "\n\n"), maxStepOutputChars*len(guardrails))
found := make([]Violation, 0, len(guardrails))
for _, g := range guardrails {
violated, err := e.checker.Check(ctx, g, GuardrailFinalSubject, joined)
if err != nil {
return found, fmt.Errorf("skills: runner: run %q: guardrail %q at %s: %w",
e.skill.Name(), g.ID, GuardrailFinalSubject, fmt.Errorf("%w: %w", ErrGuardrailCheck, err))
}
if !violated {
continue
}
v := Violation{GuardrailID: g.ID, Subject: GuardrailFinalSubject, Message: g.Message, Enforced: g.Enforced}
found = append(found, v)
if g.Enforced {
return found, e.violationError(v)
}
e.log.Warn("skill guardrail violated on final review (warn only)",
"skill", e.skill.Name(), "guardrail", g.ID, "message", g.Message)
}
return found, nil
}
// nonEmptyOutputs 取已产出步骤的输出(按声明序),跳过后面的空输出。
func nonEmptyOutputs(outputs []string, steps []sdk.Step) []string {
out := make([]string, 0, len(outputs))
for i, text := range outputs {
if text == "" || i >= len(steps) {
continue
}
out = append(out, text)
}
return out
}
// result 组装如实的结果:只汇报**已完成**的步骤(按声明序),违规清单排序后输出,
// 因此同样的执行路径总是得到同样的汇报形状。
func (e *runEnv) result(base RunResult, outcomes []StepOutcome, finished []bool, violations []Violation) RunResult {
out := base
out.Steps = make([]StepOutcome, 0, len(outcomes))
for i, oc := range outcomes {
if finished != nil && i < len(finished) && !finished[i] {
continue
}
out.Steps = append(out.Steps, oc)
}
out.Violations = sortViolations(violations)
return out
}
// sortViolations 按(对象, guardrail id)排序,让并行执行下的汇报顺序确定。
func sortViolations(in []Violation) []Violation {
out := append([]Violation(nil), in...)
sort.Slice(out, func(i, j int) bool {
if out[i].Subject != out[j].Subject {
return out[i].Subject < out[j].Subject
}
return out[i].GuardrailID < out[j].GuardrailID
})
return out
}
// drain 等在飞的步骤回传,避免留下无主 goroutine。channel 的缓冲等于并行上限,
// 因此回传不会阻塞;调用方必须保证 exec 尊重 ctx(StepExecutor 的契约)。
func (e *runEnv) drain(done chan stepDone, running int) {
for i := 0; i < running; i++ {
<-done
}
}
// violationError 把一条 enforced 违规变成可分类错误,并**如实带上哪条 guardrail、
// 在哪一步**(design/09 §6.4-4 的要求)。
func (e *runEnv) violationError(v Violation) error {
return fmt.Errorf("skills: runner: run %q: guardrail %q violated at %s: %s: %w",
e.skill.Name(), v.GuardrailID, v.Subject, v.Message, ErrGuardrailViolation)
}
// stepError 包装步骤失败:带步骤名,并用 %w 串上 ErrStepFailed 让调用方能分类。
func (e *runEnv) stepError(stepName string, err error) error {
return fmt.Errorf("skills: runner: run %q: step %q: %w: %w",
e.skill.Name(), stepName, ErrStepFailed, err)
}
// wrapCanceled 把父 ctx 的取消如实报出来(errors.Is 能命中 context.Canceled /
// context.DeadlineExceeded)。
func (e *runEnv) wrapCanceled(skillName string, err error) error {
return fmt.Errorf("skills: runner: run %q: %w", skillName, err)
}
// joinPrompt 拼接技能基础提示词与本步提示词;任一段为空时不留孤立空行。
func joinPrompt(base, step string) string {
parts := make([]string, 0, 2)
if trimmed := strings.TrimSpace(base); trimmed != "" {
parts = append(parts, trimmed)
}
if trimmed := strings.TrimSpace(step); trimmed != "" {
parts = append(parts, trimmed)
}
return strings.Join(parts, "\n\n")
}
// truncateOutput 把步骤输出截到 n 个字符以内(按 rune 计,不切断多字节字符),
// 超长时追加显式标记 —— 绝不静默截断。
func truncateOutput(s string, n int) string {
if n <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + stepTruncatedMarker
}
+367
View File
@@ -0,0 +1,367 @@
package skills
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件专测步骤调度与 guardrail 强制点(design/09 §6.4):并行上限、拓扑顺序、
// 中止语义、取消传播、收尾全量复核。
// barrierExecutor 让每一步执行前先登记,再停住等测试放行 —— 用来**观测**两路是否
// 真的同时在飞(串行实现会让第二个登记永远不来,测试超时变红)。
type barrierExecutor struct {
entered chan string
release chan struct{}
}
func (e *barrierExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) {
select {
case e.entered <- req.Step.Name:
case <-ctx.Done():
return StepResult{}, ctx.Err()
}
select {
case <-e.release:
return StepResult{Output: req.Step.Name}, nil
case <-ctx.Done():
return StepResult{}, ctx.Err()
}
}
// ctxWaitExecutor 一直等到 ctx 取消才返回,用于验证取消传播。
type ctxWaitExecutor struct {
started chan string
}
func (e *ctxWaitExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) {
select {
case e.started <- req.Step.Name:
default:
}
<-ctx.Done()
return StepResult{}, ctx.Err()
}
// TestRunner_RunSkill_无依赖步骤并行 覆盖 design/09 §6.4-1:无依赖的步骤可并行。
func TestRunner_RunSkill_无依赖步骤并行(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "parallel", []sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p"},
})
exec := &barrierExecutor{entered: make(chan string, 2), release: make(chan struct{})}
result := make(chan RunResult, 1)
errs := make(chan error, 1)
go func() {
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
result <- res
errs <- err
}()
waitEntered(t, exec.entered, 2)
close(exec.release)
if err := <-errs; err != nil {
t.Fatalf("RunSkill: %v", err)
}
res := <-result
if len(res.Steps) != 2 {
t.Fatalf("Steps = %+v, want 2 条", res.Steps)
}
}
// TestRunner_RunSkill_并行上限夹到硬顶 断言 maxStepParallel=3 是真的咬得住的:
// 4 个无依赖步骤 + 申请 100 路 ⇒ 同时最多 3 路在飞,第 4 个不会被提前放出去。
func TestRunner_RunSkill_并行上限夹到硬顶(t *testing.T) {
t.Parallel()
steps := []sdk.Step{
{Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p"},
{Name: "c", Prompt: "p"}, {Name: "d", Prompt: "p"},
}
skill := mustSkill(t, "wide", steps)
exec := &barrierExecutor{entered: make(chan string, 4), release: make(chan struct{})}
errs := make(chan error, 1)
go func() {
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, MaxParallel: 100})
errs <- err
}()
waitEntered(t, exec.entered, maxStepParallel)
select {
case got := <-exec.entered:
t.Fatalf("第 %q 步在 3 路已满时仍被放出,并行上限没咬住", got)
case <-time.After(200 * time.Millisecond):
}
close(exec.release)
if err := <-errs; err != nil {
t.Fatalf("RunSkill: %v", err)
}
}
// TestRunner_RunSkill_依赖未就绪不执行 覆盖拓扑语义:两条链 a→b、c→d,
// 每个后继都必须在自己前序完成之后才开始。
func TestRunner_RunSkill_依赖未就绪不执行(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "chain", []sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p", Depends: []string{"a"}},
{Name: "c", Prompt: "p"},
{Name: "d", Prompt: "p", Depends: []string{"c"}},
})
exec := &orderGuardExecutor{after: map[string]string{"b": "a", "d": "c"}}
if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil {
t.Fatalf("RunSkill: %v", err)
}
if exec.violation != "" {
t.Fatalf("依赖未就绪就执行了:%s", exec.violation)
}
}
// orderGuardExecutor 记录完成过的步骤,并断言某步开始时它的前序已完成。
type orderGuardExecutor struct {
mu sync.Mutex
done map[string]bool
after map[string]string
violation string
}
func (e *orderGuardExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.done == nil {
e.done = make(map[string]bool)
}
if want := e.after[req.Step.Name]; want != "" && !e.done[want] {
e.violation = req.Step.Name + " 在 " + want + " 之前就开始"
}
e.done[req.Step.Name] = true
return StepResult{Output: req.Step.Name}, nil
}
// TestRunner_RunSkill_Enforced护栏违规中止 覆盖 design/09 §6.4-4:enforced 违反
// ⇒ 中止,且错误里如实带「哪条 guardrail、哪一步」,后续步骤不再执行。
func TestRunner_RunSkill_Enforced护栏违规中止(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "guarded",
[]sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p", Depends: []string{"one"}}},
[]sdk.Guardrail{{ID: "no-pii", Message: "不许输出个人信息", Enforced: true}})
exec := &recordingExecutor{}
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec,
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(_ context.Context, g sdk.Guardrail, subject, _ string) (bool, error) {
return g.ID == "no-pii" && subject == "one", nil
}),
})
if !errors.Is(err, ErrGuardrailViolation) {
t.Fatalf("err = %v, want ErrGuardrailViolation", err)
}
if !strings.Contains(err.Error(), "no-pii") || !strings.Contains(err.Error(), "one") {
t.Fatalf("错误没如实带出哪条 guardrail / 哪一步:%v", err)
}
if got := strings.Join(exec.order(), ","); got != "one" {
t.Fatalf("执行顺序 = %q, want 只有 one(中止后 two 不得执行)", got)
}
}
// TestRunner_RunSkill_非Enforced护栏只告警 覆盖「false ⇒ WARN 后继续」。
func TestRunner_RunSkill_非Enforced护栏只告警(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "warned",
[]sdk.Step{{Name: "one", Prompt: "p"}},
[]sdk.Guardrail{{ID: "tone", Message: "语气要软", Enforced: false}})
exec := &recordingExecutor{}
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec,
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) {
return subject == "one", nil
}),
})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if len(res.Steps) != 1 {
t.Fatalf("Steps = %+v, want 1 条(WARN 不阻断)", res.Steps)
}
if len(res.Violations) == 0 || res.Violations[0].GuardrailID != "tone" || res.Violations[0].Enforced {
t.Fatalf("Violations = %+v, want tone/非 enforced 一条", res.Violations)
}
}
// TestRunner_RunSkill_护栏校验器失败即中止 断言「判不了」不等于「通过」。
func TestRunner_RunSkill_护栏校验器失败即中止(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "badcheck",
[]sdk.Step{{Name: "one", Prompt: "p"}},
[]sdk.Guardrail{{ID: "g", Message: "m", Enforced: false}})
boom := errors.New("checker down")
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: &recordingExecutor{},
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(context.Context, sdk.Guardrail, string, string) (bool, error) { return false, boom }),
})
if !errors.Is(err, ErrGuardrailCheck) || !errors.Is(err, boom) {
t.Fatalf("err = %v, want 同时命中 ErrGuardrailCheck 与原始错误", err)
}
}
// TestRunner_RunSkill_步骤失败不静默跳过 覆盖 design/09 §6.4-5:任一步失败 ⇒
// 技能失败,错误带步骤名,后继步骤不再执行,结果只含已完成的部分。
func TestRunner_RunSkill_步骤失败不静默跳过(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "failing", []sdk.Step{
{Name: "one", Prompt: "p"},
{Name: "two", Prompt: "p", Depends: []string{"one"}},
})
boom := errors.New("model exploded")
exec := &recordingExecutor{errs: map[string]error{"one": boom}}
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
})
if !errors.Is(err, ErrStepFailed) || !errors.Is(err, boom) {
t.Fatalf("err = %v, want 同时命中 ErrStepFailed 与原始错误", err)
}
if !strings.Contains(err.Error(), "one") {
t.Fatalf("错误没带出失败的步骤名:%v", err)
}
if got := strings.Join(exec.order(), ","); got != "one" {
t.Fatalf("执行顺序 = %q, want 只有 one", got)
}
if len(res.Steps) != 0 {
t.Fatalf("Steps = %+v, want 空(失败步不算完成)", res.Steps)
}
}
// TestRunner_RunSkill_取消传播 覆盖 design/09 §6.4-5:父 ctx 取消 ⇒ 技能随之取消。
func TestRunner_RunSkill_取消传播(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "cancelled", []sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p"},
})
exec := &ctxWaitExecutor{started: make(chan string, 2)}
ctx, cancel := context.WithCancel(context.Background())
errs := make(chan error, 1)
go func() {
_, err := NewRunner(registry.New(), testLogger()).RunSkill(ctx, skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
errs <- err
}()
waitEntered(t, exec.started, 1)
cancel()
select {
case err := <-errs:
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("取消后 RunSkill 没收敛(疑似遗留 goroutine 或死等)")
}
}
// TestRunner_RunSkill_收尾全量复核 覆盖 design/09 §6.4-4 的「再全量复核一次」:
// 逐步校验都通过,但累计输出违反 enforced guardrail ⇒ 仍然中止。
func TestRunner_RunSkill_收尾全量复核(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "final-check",
[]sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p"}},
[]sdk.Guardrail{{ID: "budget", Message: "总长受限", Enforced: true}})
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: &recordingExecutor{outputs: map[string]string{"one": "a", "two": "b"}},
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) {
return subject == GuardrailFinalSubject, nil
}),
})
if !errors.Is(err, ErrGuardrailViolation) {
t.Fatalf("err = %v, want ErrGuardrailViolation", err)
}
if !strings.Contains(err.Error(), GuardrailFinalSubject) {
t.Fatalf("错误没说明违规发生在全量复核阶段:%v", err)
}
}
// TestRunner_RunSkill_无步骤直接成功 断言空工作流不报错、也不造出假步骤。
func TestRunner_RunSkill_无步骤直接成功(t *testing.T) {
t.Parallel()
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), mustSkill(t, "empty", nil),
RunOptions{Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if len(res.Steps) != 0 {
t.Fatalf("Steps = %+v, want 空", res.Steps)
}
}
// TestRunner_RunSkill_下游输出有界 断言传给下游的步骤输出受 maxStepOutputChars 约束,
// 且截断有显式标记(绝不静默)。
func TestRunner_RunSkill_下游输出有界(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "bounded", []sdk.Step{
{Name: "one", Prompt: "p"},
{Name: "two", Prompt: "p", Depends: []string{"one"}},
})
exec := &recordingExecutor{outputs: map[string]string{"one": strings.Repeat("x", maxStepOutputChars+50)}}
if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil {
t.Fatalf("RunSkill: %v", err)
}
got := exec.depsOf("two")["one"]
if len([]rune(got)) != maxStepOutputChars+len([]rune(stepTruncatedMarker)) {
t.Fatalf("传给下游的输出长度 = %d,want %d", len([]rune(got)), maxStepOutputChars+len([]rune(stepTruncatedMarker)))
}
if !strings.Contains(got, "truncated") {
t.Fatalf("截断没有显式标记:%q", got[len(got)-20:])
}
}
func TestClampParallel(t *testing.T) {
t.Parallel()
cases := map[int]int{
0: defaultStepParallel, -3: defaultStepParallel, 1: 1, 2: 2, 3: maxStepParallel, 100: maxStepParallel,
}
for in, want := range cases {
if got := clampParallel(in); got != want {
t.Fatalf("clampParallel(%d) = %d, want %d", in, got, want)
}
}
}
// mustRunSkill 构造一个带 guardrail 的技能;guardrail 的存在使 Validate 之外的分支
// 也被覆盖(重复 id、冲突工具在 validate_test.go 里测)。
func mustRunSkill(t *testing.T, name string, steps []sdk.Step, guardrails []sdk.Guardrail) *Skill {
t.Helper()
skill, err := New(name, "desc "+name, "base", steps, nil, guardrails)
if err != nil {
t.Fatalf("New(%s): %v", name, err)
}
return skill
}
// waitEntered 从 ch 收 n 个登记;超时即测试失败(说明并行没发生 / 步骤没被执行)。
func waitEntered(t *testing.T, ch <-chan string, n int) {
t.Helper()
for i := 0; i < n; i++ {
select {
case <-ch:
case <-time.After(3 * time.Second):
t.Fatalf("只等到 %d/%d 个步骤进入执行(疑似串行或未调度)", i, n)
}
}
}
+90 -33
View File
@@ -15,18 +15,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 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[:])
+71 -3
View File
@@ -150,9 +150,77 @@ func TestStore_Upsert_Invalid_异常(t *testing.T) {
}
}
// TestStore_SQLInjection_安全 stores a name/description that looks like SQL and
// 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)
+135
View File
@@ -0,0 +1,135 @@
package skills
import (
"sort"
"strings"
)
// 本文件实现 design/09 §6.4-3 的「工具面 = 求交集(只收不放)」:
//
// 技能轮次可用工具 = allowed-tools(若声明)∩ 当前安全闸 ∩ 全局注册表
// − disallowed-tools
//
// 🔴 铁律:**任何情况下都不得因为技能而放宽**。因此求值函数只从「注册表给出的
// 全集」里**删**,绝不往里**加**;安全闸缺位(nil)时结果是空集(fail-closed)。
//
// 与 Claude Code 的刻意差异(见 design/09 §6.0 对照表):它的 allowed-tools 语义
// 是「免审批」,明说 *It does not restrict which tools are available*;我们的
// allowed-tools 是**可用工具的上限(子集)**,敏感/写操作仍需代审 + 审计。
// Gate 是当前回合的安全闸:actor 元数据 + `ToolPolicy` + 唤醒态允许集。
// 它由运行层(loop)构造后注入 —— 技能包不自己判定身份,也不缓存判定结果。
//
// 实现必须幂等且并发安全(步骤并行时会同时被问)。
type Gate interface {
// Allow 报告名为 name 的工具在当前安全闸下是否可用。
Allow(name string) bool
}
// GateFunc 把普通函数适配成 Gate。
type GateFunc func(name string) bool
// Allow 实现 Gate;nil 函数一律拒(fail-closed)。
func (f GateFunc) Allow(name string) bool { return f != nil && f(name) }
// NameSet 是按名字集合判定的 Gate —— 唤醒态允许集的自然形态。
type NameSet map[string]struct{}
// NewNameSet 把名字列表转成 NameSet(去空白、忽略空串)。
func NewNameSet(names []string) NameSet {
set := make(NameSet, len(names))
for _, name := range names {
if trimmed := strings.TrimSpace(name); trimmed != "" {
set[trimmed] = struct{}{}
}
}
return set
}
// Allow 实现 Gate:只有显式在集合里的名字才可用。
func (s NameSet) Allow(name string) bool {
if s == nil {
return false
}
_, ok := s[name]
return ok
}
// AllOf 把多个闸串成「全允许才允许」,用于把 actor 判定、ToolPolicy 判定与唤醒
// 允许集叠在一个 Gate 上喂给 ResolveToolSurface。
//
// **没有任何一个闸(gates 全 nil/空)时一律全拒**:宁可什么都不给,也不给一个
// 无人看守的工具面。
func AllOf(gates ...Gate) Gate {
effective := make([]Gate, 0, len(gates))
for _, g := range gates {
if g != nil {
effective = append(effective, g)
}
}
if len(effective) == 0 {
return GateFunc(nil)
}
return GateFunc(func(name string) bool {
for _, g := range effective {
if !g.Allow(name) {
return false
}
}
return true
})
}
// ResolveToolSurface 计算技能轮次实际可用的工具名。
//
// 参数:
// - registered:全局注册表给出的工具名全集(本轮 dispatcher 真正持有的工具);
// - allowed:技能的 allowed-tools;**空表示技能不额外收窄**(仍受安全闸约束);
// - disallowed:技能的 disallowed-tools,最后减掉;
// - gate:当前安全闸;nil ⇒ 结果为空集(缺闸即全拒)。
//
// 结果按 registered 的顺序输出(去重、跳过空白名),因此调用方传入确定序就得到
// 确定序 —— 函数本身不排序,避免打乱 dispatcher 的既有工具次序。
func ResolveToolSurface(registered, allowed, disallowed []string, gate Gate) []string {
if gate == nil || len(registered) == 0 {
// 缺闸或没有注册表 ⇒ 没有任何工具可用。绝不「因为技能没声明上限就放行」。
return nil
}
allowedSet := NewNameSet(allowed)
disallowedSet := NewNameSet(disallowed)
hasAllowed := len(allowedSet) > 0
out := make([]string, 0, len(registered))
seen := make(map[string]struct{}, len(registered))
for _, name := range registered {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if _, dup := seen[trimmed]; dup {
continue
}
if hasAllowed {
if _, ok := allowedSet[trimmed]; !ok {
continue
}
}
if _, blocked := disallowedSet[trimmed]; blocked {
continue
}
if !gate.Allow(trimmed) {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
// SortedCopy 返回排序后的名字副本。需要确定序的调用方(如从 map 序的注册表读
// 工具名)先用它归一,再交给 ResolveToolSurface。
func SortedCopy(names []string) []string {
out := append([]string(nil), names...)
sort.Strings(out)
return out
}
+150
View File
@@ -0,0 +1,150 @@
package skills
import (
"strings"
"testing"
)
// 本文件专测 design/09 §6.4-3 的能力收缩:**只收不放**。
func TestResolveToolSurface_表驱动(t *testing.T) {
t.Parallel()
registered := []string{"alpha", "beta", "gamma"}
cases := []struct {
name string
allowed []string
disallowed []string
gate Gate
want string
}{
{
name: "未声明上限时不额外收窄(仍受安全闸约束)",
gate: NewNameSet([]string{"alpha", "beta", "gamma"}),
want: "alpha,beta,gamma",
},
{
name: "allowed 是上限:列表外的工具不可见",
allowed: []string{"alpha"},
gate: NewNameSet([]string{"alpha", "beta", "gamma"}),
want: "alpha",
},
{
name: "disallowed 从上限里剔除",
allowed: []string{"alpha", "beta"},
disallowed: []string{"beta"},
gate: NewNameSet([]string{"alpha", "beta", "gamma"}),
want: "alpha",
},
{
name: "安全闸拒绝的工具即使被技能允许也不可用",
allowed: []string{"alpha", "beta"},
gate: NewNameSet([]string{"alpha"}),
want: "alpha",
},
{
name: "allowed 里的幽灵工具不会凭空出现(∩ 注册表)",
allowed: []string{"alpha", "ghost"},
gate: NewNameSet([]string{"alpha", "ghost"}),
want: "alpha",
},
{
name: "缺安全闸 ⇒ 全拒(fail-closed)",
gate: nil,
want: "",
},
{
name: "空注册表 ⇒ 空面",
gate: NewNameSet([]string{"alpha"}),
want: "",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tools := registered
if tc.name == "空注册表 ⇒ 空面" {
tools = nil
}
got := ResolveToolSurface(tools, tc.allowed, tc.disallowed, tc.gate)
if strings.Join(got, ",") != tc.want {
t.Fatalf("ResolveToolSurface = %q, want %q", strings.Join(got, ","), tc.want)
}
})
}
}
// TestResolveToolSurface_只收不放 是本片的核心不变式:无论技能怎么声明,结果永远
// 是「注册表 ∩ 安全闸」的子集 —— 技能**加不进**任何东西。
func TestResolveToolSurface_只收不放(t *testing.T) {
t.Parallel()
registered := []string{"alpha", "beta"}
gate := NewNameSet([]string{"alpha", "beta"})
// 技能把 allowed-tools 写成注册表之外的、或安全闸之外的,都不该出现在结果里。
got := ResolveToolSurface(registered, []string{"ghost", "secret"}, nil, gate)
if len(got) != 0 {
t.Fatalf("ResolveToolSurface = %v, want 空(技能不得放宽任何工具)", got)
}
// 反过来:技能把*所有*注册表工具都写进 allowed,也只能拿到安全闸允许的那些。
got = ResolveToolSurface(registered, registered, nil, NewNameSet([]string{"beta"}))
if strings.Join(got, ",") != "beta" {
t.Fatalf("ResolveToolSurface = %v, want [beta]", got)
}
}
func TestResolveToolSurface_去重与保序(t *testing.T) {
t.Parallel()
got := ResolveToolSurface([]string{"b", "a", "b", " ", "c"}, nil, nil, NewNameSet([]string{"a", "b", "c"}))
if strings.Join(got, ",") != "b,a,c" {
t.Fatalf("ResolveToolSurface = %q, want b,a,c(保注册表序 + 去重 + 跳空白)", strings.Join(got, ","))
}
}
func TestAllOf_全允许才允许(t *testing.T) {
t.Parallel()
allowAll := NewNameSet([]string{"a", "b"})
onlyA := NewNameSet([]string{"a"})
if !AllOf(allowAll, onlyA).Allow("a") {
t.Fatal("AllOf(allowAll, onlyA).Allow(a) = false, want true")
}
if AllOf(allowAll, onlyA).Allow("b") {
t.Fatal("AllOf(allowAll, onlyA).Allow(b) = true, want false")
}
if AllOf(allowAll, GateFunc(nil)).Allow("a") {
t.Fatal("含 nil 函数闸时应当全拒")
}
// 没有任何有效闸 ⇒ 全拒(绝不给出无人看守的工具面)。
if AllOf(nil, nil).Allow("a") {
t.Fatal("AllOf() 全 nil 时应当全拒")
}
if AllOf().Allow("a") {
t.Fatal("AllOf() 无参时应当全拒")
}
}
func TestNameSet_语义(t *testing.T) {
t.Parallel()
set := NewNameSet([]string{" a ", "", "b"})
if !set.Allow("a") || !set.Allow("b") {
t.Fatalf("NameSet = %v, want 含 a/b(并去掉空白)", set)
}
if set.Allow("") || set.Allow("c") {
t.Fatal("NameSet 不该放行未登记的名字")
}
var nilSet NameSet
if nilSet.Allow("a") {
t.Fatal("nil NameSet 必须全拒")
}
}
func TestSortedCopy_不改原切片(t *testing.T) {
t.Parallel()
in := []string{"b", "a"}
got := SortedCopy(in)
if strings.Join(got, ",") != "a,b" {
t.Fatalf("SortedCopy = %v, want [a b]", got)
}
if in[0] != "b" {
t.Fatalf("SortedCopy 改动了入参:%v", in)
}
}
+159
View File
@@ -0,0 +1,159 @@
package skills
import (
"fmt"
"path"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Validate 校验技能定义的结构约束(design/09 §6.2 的「校验(必做,错误可分类)」):
//
// - steps[].name 非空、不重复(重复 ⇒ ErrStepDuplicate);
// - steps[].depends 指向存在的步骤(否则 ErrStepDependsMissing);
// - 步骤依赖无环(有环 ⇒ ErrStepCycle,**加载期就拒,运行期绝不挂死**);
// - allowed-tools 与 disallowed-tools 无同名(否则 ErrToolConflict);
// - guardrails[].id 不重复(否则 ErrGuardrailDuplicate);
// - invocation / context 取值合法(否则 sdk.ErrInvalidSkillField);
// - paths 的 glob 语法合法(否则 sdk.ErrInvalidSkillField)。
//
// 加载器在解析后调用它(Load/LoadFile/LoadDir 都过这一关),Runner 在开工前
// 再调用一次 —— 校验器对外部直接构造的 sdk.Skill 同样有效。
func Validate(s sdk.Skill) error {
if s == nil || strings.TrimSpace(s.Name()) == "" {
return fmt.Errorf("skills: validate: %w", ErrInvalidSkill)
}
name := s.Name()
spec := sdk.SkillView(s)
if !spec.Invocation().Valid() {
return fmt.Errorf("skills: validate %q: invocation %q: %w", name, spec.Invocation(), sdk.ErrInvalidSkillField)
}
if !spec.ContextMode().Valid() {
return fmt.Errorf("skills: validate %q: context %q: %w", name, spec.ContextMode(), sdk.ErrInvalidSkillField)
}
if err := validatePaths(name, spec.Paths()); err != nil {
return err
}
if err := validateToolDecls(name, spec.AllowedTools(), spec.DisallowedTools()); err != nil {
return err
}
if err := validateGuardrails(name, s.Guardrails()); err != nil {
return err
}
_, err := TopoOrder(s.Steps())
if err != nil {
return fmt.Errorf("skills: validate %q: %w", name, err)
}
return nil
}
// validatePaths 检查每个 glob 的语法(用 OS 无关的 path.Match 做语法体检:它只
// 报语法错,不做真匹配)。语法错会让「按工作文件自动激活」永远不命中,属于静默
// 失效,因此在加载期就拒。
func validatePaths(name string, patterns []string) error {
for _, p := range patterns {
if _, err := path.Match(p, ""); err != nil {
return fmt.Errorf("skills: validate %q: paths %q: %w", name, p, sdk.ErrInvalidSkillField)
}
}
return nil
}
// validateToolDecls 拒绝「同一工具同时被允许与被禁止」的自相矛盾声明。
func validateToolDecls(name string, allowed, disallowed []string) error {
if len(allowed) == 0 || len(disallowed) == 0 {
return nil
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, tool := range allowed {
allowedSet[strings.TrimSpace(tool)] = struct{}{}
}
for _, tool := range disallowed {
if _, hit := allowedSet[strings.TrimSpace(tool)]; hit {
return fmt.Errorf("skills: validate %q: tool %q: %w", name, tool, ErrToolConflict)
}
}
return nil
}
// validateGuardrails 拒绝重复的 guardrail id(重复让「哪条 guardrail」说不清)。
func validateGuardrails(name string, guardrails []sdk.Guardrail) error {
seen := make(map[string]struct{}, len(guardrails))
for _, g := range guardrails {
id := strings.TrimSpace(g.ID)
if _, dup := seen[id]; dup {
return fmt.Errorf("skills: validate %q: guardrail %q: %w", name, g.ID, ErrGuardrailDuplicate)
}
seen[id] = struct{}{}
}
return nil
}
// TopoOrder 返回按依赖拓扑排序后的步骤名。就绪步骤按**声明顺序**出队,因此输出
// 是确定的(同样的定义永远得到同样的顺序)。有任何一步还没排进去就说明依赖成环,
// 此时返回 ErrStepCycle 并如实列出未排入的步骤名。
//
// 步骤名重复、为空、或 depends 指向不存在的步骤,都会先在这里被拒。
func TopoOrder(steps []sdk.Step) ([]string, error) {
if len(steps) == 0 {
return nil, nil
}
index := make(map[string]int, len(steps))
remaining := make([]int, len(steps))
dependents := make([][]int, len(steps))
for i, st := range steps {
name := strings.TrimSpace(st.Name)
if name == "" {
return nil, fmt.Errorf("skills: topo order: step #%d has a blank name: %w", i, ErrInvalidSkill)
}
if _, dup := index[name]; dup {
return nil, fmt.Errorf("skills: topo order: step %q: %w", name, ErrStepDuplicate)
}
index[name] = i
}
for i, st := range steps {
for _, dep := range st.Depends {
depIdx, ok := index[strings.TrimSpace(dep)]
if !ok {
return nil, fmt.Errorf("skills: topo order: step %q depends on unknown step %q: %w",
st.Name, dep, ErrStepDependsMissing)
}
remaining[i]++
dependents[depIdx] = append(dependents[depIdx], i)
}
}
queue := make([]int, 0, len(steps))
for i := range steps {
if remaining[i] == 0 {
queue = append(queue, i)
}
}
order := make([]string, 0, len(steps))
for len(queue) > 0 {
idx := queue[0]
queue = queue[1:]
order = append(order, steps[idx].Name)
for _, dep := range dependents[idx] {
remaining[dep]--
if remaining[dep] == 0 {
queue = append(queue, dep)
}
}
}
if len(order) != len(steps) {
return nil, fmt.Errorf("skills: topo order: steps %v: %w", unsortedStepNames(steps, remaining), ErrStepCycle)
}
return order, nil
}
// unsortedStepNames 收集没排进拓扑序的步骤名(按声明序,错误信息因此确定)。
func unsortedStepNames(steps []sdk.Step, remaining []int) []string {
out := make([]string, 0, len(steps))
for i, st := range steps {
if remaining[i] > 0 {
out = append(out, st.Name)
}
}
return out
}
+192 -25
View File
@@ -1,55 +1,222 @@
package sdk
// 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