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

+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]...)
}